From 841304a090a656e9fefb7e376e28706947de079a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:29:14 +0000 Subject: [PATCH 1/4] Refine Scout's searchable model contract Treat the Searchable trait as the application-facing model contract while keeping SearchableInterface as an internal static-analysis shape. Widen public engine, command, job, event, and collection boundaries to ordinary Eloquent models, then narrow only where Scout capabilities are consumed. This removes the need for application models to implement an extra interface and keeps the API aligned with Laravel Scout. Preserve collection types through model hooks, remove redundant callback annotations, and make intentional loose Scout-key matching explicit where search services serialize numeric keys as strings. --- src/scout/src/Builder.php | 5 +-- src/scout/src/Console/IndexCommand.php | 2 +- src/scout/src/Console/QueueImportCommand.php | 9 ++++-- .../src/Console/SyncIndexSettingsCommand.php | 2 +- .../src/Contracts/SearchableInterface.php | 19 ++++++++++-- src/scout/src/Engines/AlgoliaEngine.php | 31 +++++++------------ src/scout/src/Engines/CollectionEngine.php | 18 +++++------ src/scout/src/Engines/DatabaseEngine.php | 20 +++++------- src/scout/src/Engines/Engine.php | 11 ++----- src/scout/src/Engines/MeilisearchEngine.php | 31 +++++++------------ src/scout/src/Engines/TypesenseEngine.php | 21 +++++-------- src/scout/src/Events/ModelsFlushed.php | 3 +- src/scout/src/Events/ModelsImported.php | 3 +- src/scout/src/Jobs/MakeSearchable.php | 2 +- src/scout/src/Jobs/RemoveFromSearch.php | 2 +- .../src/Jobs/RemoveableScoutCollection.php | 9 ++++-- src/scout/src/ModelObserver.php | 2 +- src/scout/src/Scout.php | 5 ++- src/scout/src/Searchable.php | 10 ++++-- src/scout/src/SearchableScope.php | 15 ++++++--- src/scout/src/Traits/UniqueByScoutKeys.php | 3 +- 21 files changed, 108 insertions(+), 115 deletions(-) diff --git a/src/scout/src/Builder.php b/src/scout/src/Builder.php index e7b546238..5d1df2acc 100644 --- a/src/scout/src/Builder.php +++ b/src/scout/src/Builder.php @@ -27,7 +27,7 @@ /** * Fluent search query builder for searchable models. * - * @template TModel of Model&SearchableInterface + * @template TModel of Model */ class Builder { @@ -38,7 +38,7 @@ class Builder /** * The model instance. * - * @var TModel + * @var SearchableInterface&TModel */ public Model $model; @@ -118,6 +118,7 @@ public function __construct( ?Closure $callback = null, bool $softDelete = false ) { + /** @var SearchableInterface&TModel $model */ $this->model = $model; $this->query = $query; $this->callback = $callback; diff --git a/src/scout/src/Console/IndexCommand.php b/src/scout/src/Console/IndexCommand.php index 5c28276f1..d757f293c 100644 --- a/src/scout/src/Console/IndexCommand.php +++ b/src/scout/src/Console/IndexCommand.php @@ -70,7 +70,7 @@ public function handle(EngineManager $manager, Repository $config): int if ($model !== null && $config->boolean('scout.soft_delete', false) - && in_array(SoftDeletes::class, class_uses_recursive($model))) { + && in_array(SoftDeletes::class, class_uses_recursive($model), true)) { $settings = $engine->configureSoftDeleteFilter($settings); } diff --git a/src/scout/src/Console/QueueImportCommand.php b/src/scout/src/Console/QueueImportCommand.php index 3c6990183..5f22f6c93 100644 --- a/src/scout/src/Console/QueueImportCommand.php +++ b/src/scout/src/Console/QueueImportCommand.php @@ -6,6 +6,7 @@ use Hypervel\Config\Repository; use Hypervel\Console\Command; +use Hypervel\Database\Eloquent\Model; use Hypervel\Scout\Console\Traits\ResolvesScoutModelClass; use Hypervel\Scout\Contracts\SearchableInterface; use Hypervel\Scout\Exceptions\ScoutException; @@ -45,7 +46,7 @@ public function handle(Repository $config): int { $class = $this->resolveModelClass((string) $this->argument('model')); - /** @var SearchableInterface $model */ + /** @var Model&SearchableInterface $model */ $model = new $class; $chunk = max(1, (int) ($this->option('chunk') ?? $config->integer('scout.chunk.searchable', 500))); @@ -67,8 +68,9 @@ public function handle(Repository $config): int /** * Dispatch range jobs for an integer-keyed model using min/max arithmetic. */ - protected function dispatchIntegerRange(string $class, SearchableInterface $model, int $chunk, string $order, ?string $queueName, ?string $connection): int + protected function dispatchIntegerRange(string $class, Model $model, int $chunk, string $order, ?string $queueName, ?string $connection): int { + /** @var Model&SearchableInterface $model */ $query = $class::makeAllSearchableQuery(); $keyName = $model->getScoutKeyName(); $qualified = $query->qualifyColumn($keyName); @@ -142,8 +144,9 @@ protected function dispatchIntegerRange(string $class, SearchableInterface $mode * dispatches one MakeRangeSearchable per chunk with the first/last keys * in that chunk. Workers re-query their range via whereBetween. */ - protected function dispatchStringRange(string $class, SearchableInterface $model, int $chunk, string $order, ?string $queueName, ?string $connection): int + protected function dispatchStringRange(string $class, Model $model, int $chunk, string $order, ?string $queueName, ?string $connection): int { + /** @var Model&SearchableInterface $model */ $query = $class::makeAllSearchableQuery(); $keyName = $model->getScoutKeyName(); $qualified = $query->qualifyColumn($keyName); diff --git a/src/scout/src/Console/SyncIndexSettingsCommand.php b/src/scout/src/Console/SyncIndexSettingsCommand.php index f07a4b915..0d721836c 100644 --- a/src/scout/src/Console/SyncIndexSettingsCommand.php +++ b/src/scout/src/Console/SyncIndexSettingsCommand.php @@ -69,7 +69,7 @@ public function handle(EngineManager $manager, Repository $config): int if ($model !== null && $config->boolean('scout.soft_delete', false) - && in_array(SoftDeletes::class, class_uses_recursive($model))) { + && in_array(SoftDeletes::class, class_uses_recursive($model), true)) { $settings = $engine->configureSoftDeleteFilter($settings); } diff --git a/src/scout/src/Contracts/SearchableInterface.php b/src/scout/src/Contracts/SearchableInterface.php index dd6b3bb8f..8ed44bddc 100644 --- a/src/scout/src/Contracts/SearchableInterface.php +++ b/src/scout/src/Contracts/SearchableInterface.php @@ -7,14 +7,16 @@ use Closure; use Hypervel\Database\Eloquent\Builder as EloquentBuilder; use Hypervel\Database\Eloquent\Collection; +use Hypervel\Database\Eloquent\Model; use Hypervel\Scout\Builder; use Hypervel\Scout\Engines\Engine; /** - * Contract for models that can be indexed and searched. + * Internal shape for models that can be indexed and searched. * - * This interface defines the public API that searchable models must implement. - * The Searchable trait provides a default implementation of these methods. + * Application models gain this capability through the Searchable trait and do + * not need to implement this interface directly. Keep this shape compatible + * with the trait while widening collection boundaries to Model for engines. * * @phpstan-require-extends \Hypervel\Database\Eloquent\Model */ @@ -22,21 +24,32 @@ interface SearchableInterface { /** * Perform a search against the model's indexed data. + * + * @return Builder */ public static function search(string $query = '', ?Closure $callback = null): Builder; /** * Get the requested models from an array of object IDs. + * + * @param array $ids + * @return Collection */ public function getScoutModelsByIds(Builder $builder, array $ids): Collection; /** * Get a query builder for retrieving the requested models from an array of object IDs. + * + * @param array $ids + * @return EloquentBuilder */ public function queryScoutModelsByIds(Builder $builder, array $ids): EloquentBuilder; /** * Modify the collection of models being made searchable. + * + * @param Collection $models + * @return Collection */ public function makeSearchableUsing(Collection $models): Collection; diff --git a/src/scout/src/Engines/AlgoliaEngine.php b/src/scout/src/Engines/AlgoliaEngine.php index aa5a5f77d..a3082b223 100644 --- a/src/scout/src/Engines/AlgoliaEngine.php +++ b/src/scout/src/Engines/AlgoliaEngine.php @@ -55,7 +55,7 @@ public function __construct( /** * Update the given models in the search index. * - * @param EloquentCollection $models + * @param EloquentCollection $models * @throws AlgoliaException */ public function update(EloquentCollection $models): void @@ -64,7 +64,7 @@ public function update(EloquentCollection $models): void return; } - /** @var Model&SearchableInterface $firstModel */ + /** @var EloquentCollection $models */ $firstModel = $models->first(); $index = $firstModel->indexableAs(); @@ -73,7 +73,6 @@ public function update(EloquentCollection $models): void } $objects = $models->map(function (Model $model) { - /** @var Model&SearchableInterface $model */ $searchableData = $model->toSearchableArray(); if (empty($searchableData)) { @@ -100,7 +99,7 @@ public function update(EloquentCollection $models): void /** * Remove the given models from the search index. * - * @param EloquentCollection $models + * @param EloquentCollection $models */ public function delete(EloquentCollection $models): void { @@ -108,12 +107,12 @@ public function delete(EloquentCollection $models): void return; } - /** @var Model&SearchableInterface $firstModel */ + /** @var EloquentCollection $models */ $firstModel = $models->first(); $keys = $models instanceof RemoveableScoutCollection ? $models->pluck($firstModel->getScoutKeyName()) - : $models->map(fn (SearchableInterface $model) => $model->getScoutKey()); + : $models->map(fn (Model $model) => $model->getScoutKey()); $this->algolia->deleteObjects($firstModel->indexableAs(), $keys->all()); } @@ -342,11 +341,10 @@ public function mapIds(mixed $results): Collection /** * Map the given results to instances of the given model. - * - * @param Model&SearchableInterface $model */ public function map(Builder $builder, mixed $results, Model $model): EloquentCollection { + /** @var Model&SearchableInterface $model */ if (count($results['hits']) === 0) { return $model->newCollection(); } @@ -356,13 +354,12 @@ public function map(Builder $builder, mixed $results, Model $model): EloquentCol /** @var array $objectIds */ $objectIdPositions = array_flip($objectIds); - /** @var EloquentCollection $scoutModels */ $scoutModels = $model->getScoutModelsByIds($builder, $objectIds); + // Search engines serialize numeric Scout keys as strings. $mapped = $scoutModels - ->filter(fn ($m) => in_array($m->getScoutKey(), $objectIds)) + ->filter(fn ($m) => in_array($m->getScoutKey(), $objectIds, false)) ->map(function ($m) use ($results, $objectIdPositions) { - /** @var Model&SearchableInterface $m */ $result = $results['hits'][$objectIdPositions[$m->getScoutKey()]] ?? []; foreach ($result as $key => $value) { @@ -381,11 +378,10 @@ public function map(Builder $builder, mixed $results, Model $model): EloquentCol /** * Map the given results to instances of the given model via a lazy collection. - * - * @param Model&SearchableInterface $model */ public function lazyMap(Builder $builder, mixed $results, Model $model): LazyCollection { + /** @var Model&SearchableInterface $model */ if (count($results['hits']) === 0) { return LazyCollection::empty(); } @@ -395,13 +391,11 @@ public function lazyMap(Builder $builder, mixed $results, Model $model): LazyCol /** @var array $objectIds */ $objectIdPositions = array_flip($objectIds); - /** @var LazyCollection $cursor */ $cursor = $model->queryScoutModelsByIds($builder, $objectIds)->cursor(); return $cursor - ->filter(fn ($m) => in_array($m->getScoutKey(), $objectIds)) + ->filter(fn ($m) => in_array($m->getScoutKey(), $objectIds, false)) ->map(function ($m) use ($results, $objectIdPositions) { - /** @var Model&SearchableInterface $m */ $result = $results['hits'][$objectIdPositions[$m->getScoutKey()]] ?? []; foreach ($result as $key => $value) { @@ -426,11 +420,10 @@ public function getTotalCount(mixed $results): int /** * Flush all of the model's records from the engine. - * - * @param Model&SearchableInterface $model */ public function flush(Model $model): void { + /** @var Model&SearchableInterface $model */ $this->algolia->clearObjects($model->indexableAs()); } @@ -539,7 +532,7 @@ public function configureSoftDeleteFilter(array $settings = []): array */ protected function usesSoftDelete(Model $model): bool { - return in_array(SoftDeletes::class, class_uses_recursive($model)); + return in_array(SoftDeletes::class, class_uses_recursive($model), true); } /** diff --git a/src/scout/src/Engines/CollectionEngine.php b/src/scout/src/Engines/CollectionEngine.php index 32de4208e..e979fd3f9 100644 --- a/src/scout/src/Engines/CollectionEngine.php +++ b/src/scout/src/Engines/CollectionEngine.php @@ -115,7 +115,7 @@ protected function searchModels(Builder $builder): EloquentCollection ); }); - /** @var EloquentCollection $models */ + /** @var EloquentCollection $models */ $models = $this->ensureSoftDeletesAreHandled($builder, $query) ->get() ->values(); @@ -127,7 +127,6 @@ protected function searchModels(Builder $builder): EloquentCollection /** @var Model&SearchableInterface $firstModel */ $firstModel = $models->first(); - /** @var EloquentCollection $searchableModels */ $searchableModels = $firstModel->makeSearchableUsing($models); return $searchableModels @@ -179,7 +178,7 @@ protected function ensureSoftDeletesAreHandled(Builder $builder, EloquentBuilder return $query->onlyTrashed(); } - if (in_array(SoftDeletes::class, class_uses_recursive(get_class($builder->model))) + if (in_array(SoftDeletes::class, class_uses_recursive(get_class($builder->model)), true) && $this->getScoutConfig('soft_delete', false) ) { /* @phpstan-ignore method.notFound (SoftDeletingScope adds this method) */ @@ -206,11 +205,10 @@ public function mapIds(mixed $results): Collection /** * Map the given results to instances of the given model. - * - * @param Model&SearchableInterface $model */ public function map(Builder $builder, mixed $results, Model $model): EloquentCollection { + /** @var Model&SearchableInterface $model */ $results = $results['results']; if (count($results) === 0) { @@ -225,22 +223,21 @@ public function map(Builder $builder, mixed $results, Model $model): EloquentCol /** @var array $objectIds */ $objectIdPositions = array_flip($objectIds); - /** @var EloquentCollection $scoutModels */ $scoutModels = $model->getScoutModelsByIds($builder, $objectIds); + // Scout keys may be cast differently between results and hydrated models. return $scoutModels - ->filter(fn ($m) => in_array($m->getScoutKey(), $objectIds)) + ->filter(fn ($m) => in_array($m->getScoutKey(), $objectIds, false)) ->sortBy(fn ($m) => $objectIdPositions[$m->getScoutKey()]) ->values(); } /** * Map the given results to instances of the given model via a lazy collection. - * - * @param Model&SearchableInterface $model */ public function lazyMap(Builder $builder, mixed $results, Model $model): LazyCollection { + /** @var Model&SearchableInterface $model */ $results = $results['results']; if (count($results) === 0) { @@ -255,11 +252,10 @@ public function lazyMap(Builder $builder, mixed $results, Model $model): LazyCol /** @var array $objectIds */ $objectIdPositions = array_flip($objectIds); - /** @var LazyCollection $cursor */ $cursor = $model->queryScoutModelsByIds($builder, $objectIds)->cursor(); return $cursor - ->filter(fn ($m) => in_array($m->getScoutKey(), $objectIds)) + ->filter(fn ($m) => in_array($m->getScoutKey(), $objectIds, false)) ->sortBy(fn ($m) => $objectIdPositions[$m->getScoutKey()]) ->values(); } diff --git a/src/scout/src/Engines/DatabaseEngine.php b/src/scout/src/Engines/DatabaseEngine.php index ff76468bf..959ed1e42 100644 --- a/src/scout/src/Engines/DatabaseEngine.php +++ b/src/scout/src/Engines/DatabaseEngine.php @@ -203,9 +203,9 @@ protected function initializeSearchQuery( return $query->where(function (EloquentBuilder $query) use ($connectionType, $builder, $columns, $prefixColumns, $fullTextColumns): void { $canSearchPrimaryKey = ctype_digit((string) $builder->query) - && in_array($builder->model->getKeyType(), ['int', 'integer']) + && in_array($builder->model->getKeyType(), ['int', 'integer'], true) && ($connectionType !== 'pgsql' || (int) $builder->query <= PHP_INT_MAX) - && in_array($builder->model->getScoutKeyName(), $columns); + && in_array($builder->model->getScoutKeyName(), $columns, true); if ($canSearchPrimaryKey) { $query->orWhere($builder->model->getQualifiedKeyName(), $builder->query); @@ -214,7 +214,7 @@ protected function initializeSearchQuery( $likeOperator = $connectionType === 'pgsql' ? 'ilike' : 'like'; foreach ($columns as $column) { - if (in_array($column, $fullTextColumns)) { + if (in_array($column, $fullTextColumns, true)) { continue; } @@ -222,7 +222,7 @@ protected function initializeSearchQuery( continue; } - $pattern = in_array($column, $prefixColumns) + $pattern = in_array($column, $prefixColumns, true) ? $builder->query . '%' : '%' . $builder->query . '%'; @@ -341,7 +341,8 @@ protected function constrainForSoftDeletes(Builder $builder, EloquentBuilder $qu $usesSoftDeletes = in_array( SoftDeletes::class, - class_uses_recursive(get_class($builder->model)) + class_uses_recursive(get_class($builder->model)), + true ); if ($usesSoftDeletes && $this->getConfig('soft_delete', false)) { @@ -445,7 +446,6 @@ public function mapIds(mixed $results): Collection /** * Map the given results to instances of the given model. * - * @param Model&SearchableInterface $model * @return EloquentCollection */ public function map(Builder $builder, mixed $results, Model $model): EloquentCollection @@ -455,8 +455,6 @@ public function map(Builder $builder, mixed $results, Model $model): EloquentCol /** * Map the given results to instances of the given model via a lazy collection. - * - * @param Model&SearchableInterface $model */ public function lazyMap(Builder $builder, mixed $results, Model $model): LazyCollection { @@ -479,7 +477,7 @@ public function getTotalCount(mixed $results): int * * The database engine doesn't need to update an external index. * - * @param EloquentCollection $models + * @param EloquentCollection $models */ public function update(EloquentCollection $models): void { @@ -491,7 +489,7 @@ public function update(EloquentCollection $models): void * * The database engine doesn't need to remove from an external index. * - * @param EloquentCollection $models + * @param EloquentCollection $models */ public function delete(EloquentCollection $models): void { @@ -500,8 +498,6 @@ public function delete(EloquentCollection $models): void /** * Flush all of the model's records from the engine. - * - * @param Model&SearchableInterface $model */ public function flush(Model $model): void { diff --git a/src/scout/src/Engines/Engine.php b/src/scout/src/Engines/Engine.php index 0be587c06..094ca7dce 100644 --- a/src/scout/src/Engines/Engine.php +++ b/src/scout/src/Engines/Engine.php @@ -7,7 +7,6 @@ use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Database\Eloquent\Model; use Hypervel\Scout\Builder; -use Hypervel\Scout\Contracts\SearchableInterface; use Hypervel\Support\Collection; use Hypervel\Support\LazyCollection; @@ -22,14 +21,14 @@ abstract class Engine /** * Update the given models in the search index. * - * @param EloquentCollection $models + * @param EloquentCollection $models */ abstract public function update(EloquentCollection $models): void; /** * Remove the given models from the search index. * - * @param EloquentCollection $models + * @param EloquentCollection $models */ abstract public function delete(EloquentCollection $models): void; @@ -50,15 +49,11 @@ abstract public function mapIds(mixed $results): Collection; /** * Map the given results to instances of the given model. - * - * @param Model&SearchableInterface $model */ abstract public function map(Builder $builder, mixed $results, Model $model): EloquentCollection; /** * Map the given results to instances of the given model via a lazy collection. - * - * @param Model&SearchableInterface $model */ abstract public function lazyMap(Builder $builder, mixed $results, Model $model): LazyCollection; @@ -69,8 +64,6 @@ abstract public function getTotalCount(mixed $results): int; /** * Flush all of the model's records from the engine. - * - * @param Model&SearchableInterface $model */ abstract public function flush(Model $model): void; diff --git a/src/scout/src/Engines/MeilisearchEngine.php b/src/scout/src/Engines/MeilisearchEngine.php index ea861cd00..9caed2ffe 100644 --- a/src/scout/src/Engines/MeilisearchEngine.php +++ b/src/scout/src/Engines/MeilisearchEngine.php @@ -54,7 +54,7 @@ public function __construct( /** * Update the given models in the search index. * - * @param EloquentCollection $models + * @param EloquentCollection $models * @throws ApiException */ public function update(EloquentCollection $models): void @@ -63,7 +63,7 @@ public function update(EloquentCollection $models): void return; } - /** @var Model&SearchableInterface $firstModel */ + /** @var EloquentCollection $models */ $firstModel = $models->first(); $index = $this->meilisearch->index($firstModel->indexableAs()); @@ -72,7 +72,6 @@ public function update(EloquentCollection $models): void } $objects = $models->map(function (Model $model) { - /** @var Model&SearchableInterface $model */ $searchableData = $model->toSearchableArray(); if (empty($searchableData)) { @@ -99,7 +98,7 @@ public function update(EloquentCollection $models): void /** * Remove the given models from the search index. * - * @param EloquentCollection $models + * @param EloquentCollection $models */ public function delete(EloquentCollection $models): void { @@ -107,13 +106,13 @@ public function delete(EloquentCollection $models): void return; } - /** @var Model&SearchableInterface $firstModel */ + /** @var EloquentCollection $models */ $firstModel = $models->first(); $index = $this->meilisearch->index($firstModel->indexableAs()); $keys = $models instanceof RemoveableScoutCollection ? $models->pluck($firstModel->getScoutKeyName())->values()->all() - : $models->map(fn (SearchableInterface $model) => $model->getScoutKey())->values()->all(); + : $models->map(fn (Model $model) => $model->getScoutKey())->values()->all(); $index->deleteDocuments($keys); } @@ -310,11 +309,10 @@ public function keys(Builder $builder): Collection /** * Map the given results to instances of the given model. - * - * @param Model&SearchableInterface $model */ public function map(Builder $builder, mixed $results, Model $model): EloquentCollection { + /** @var Model&SearchableInterface $model */ if ($results === null || count($results['hits']) === 0) { return $model->newCollection(); } @@ -327,13 +325,12 @@ public function map(Builder $builder, mixed $results, Model $model): EloquentCol /** @var array $objectIds */ $objectIdPositions = array_flip($objectIds); - /** @var EloquentCollection $scoutModels */ $scoutModels = $model->getScoutModelsByIds($builder, $objectIds); + // Search engines serialize numeric Scout keys as strings. $mapped = $scoutModels - ->filter(fn ($m) => in_array($m->getScoutKey(), $objectIds)) + ->filter(fn ($m) => in_array($m->getScoutKey(), $objectIds, false)) ->map(function ($m) use ($results, $objectIdPositions) { - /** @var Model&SearchableInterface $m */ $result = $results['hits'][$objectIdPositions[$m->getScoutKey()]] ?? []; foreach ($result as $key => $value) { @@ -352,11 +349,10 @@ public function map(Builder $builder, mixed $results, Model $model): EloquentCol /** * Map the given results to instances of the given model via a lazy collection. - * - * @param Model&SearchableInterface $model */ public function lazyMap(Builder $builder, mixed $results, Model $model): LazyCollection { + /** @var Model&SearchableInterface $model */ if (count($results['hits']) === 0) { return LazyCollection::empty(); } @@ -369,13 +365,11 @@ public function lazyMap(Builder $builder, mixed $results, Model $model): LazyCol /** @var array $objectIds */ $objectIdPositions = array_flip($objectIds); - /** @var LazyCollection $cursor */ $cursor = $model->queryScoutModelsByIds($builder, $objectIds)->cursor(); return $cursor - ->filter(fn ($m) => in_array($m->getScoutKey(), $objectIds)) + ->filter(fn ($m) => in_array($m->getScoutKey(), $objectIds, false)) ->map(function ($m) use ($results, $objectIdPositions) { - /** @var Model&SearchableInterface $m */ $result = $results['hits'][$objectIdPositions[$m->getScoutKey()]] ?? []; foreach ($result as $key => $value) { @@ -400,11 +394,10 @@ public function getTotalCount(mixed $results): int /** * Flush all of the model's records from the engine. - * - * @param Model&SearchableInterface $model */ public function flush(Model $model): void { + /** @var Model&SearchableInterface $model */ $index = $this->meilisearch->index($model->indexableAs()); $index->deleteAllDocuments(); @@ -588,7 +581,7 @@ public function generateTenantToken( */ protected function usesSoftDelete(Model $model): bool { - return in_array(SoftDeletes::class, class_uses_recursive($model)); + return in_array(SoftDeletes::class, class_uses_recursive($model), true); } /** diff --git a/src/scout/src/Engines/TypesenseEngine.php b/src/scout/src/Engines/TypesenseEngine.php index 0c5a0e9e5..5f6c0c8cb 100644 --- a/src/scout/src/Engines/TypesenseEngine.php +++ b/src/scout/src/Engines/TypesenseEngine.php @@ -49,7 +49,7 @@ public function __construct( /** * Update the given models in the search index. * - * @param EloquentCollection $models + * @param EloquentCollection $models * @throws TypesenseClientError */ public function update(EloquentCollection $models): void @@ -58,7 +58,7 @@ public function update(EloquentCollection $models): void return; } - /** @var Model&SearchableInterface $firstModel */ + /** @var EloquentCollection $models */ $firstModel = $models->first(); if ($this->usesSoftDelete($firstModel) && $this->getConfig('soft_delete', false)) { @@ -66,7 +66,6 @@ public function update(EloquentCollection $models): void } $objects = $models->map(function (Model $model): ?array { - /** @var Model&SearchableInterface $model */ $searchableData = $model->toSearchableArray(); if (empty($searchableData)) { @@ -149,13 +148,13 @@ protected function createImportSortingDataObject(array $document): stdClass /** * Remove the given models from the search index. * - * @param EloquentCollection $models + * @param EloquentCollection $models * @throws TypesenseClientError */ public function delete(EloquentCollection $models): void { + /** @var EloquentCollection $models */ $models->each(function (Model $model) use ($models): void { - /** @var Model&SearchableInterface $model */ $modelId = $models instanceof RemoveableScoutCollection ? $model->getAttribute($model->getScoutKeyName()) : $model->getScoutKey(); @@ -495,11 +494,11 @@ public function mapIds(mixed $results): Collection /** * Map the given results to instances of the given model. * - * @param Model&SearchableInterface $model * @return EloquentCollection */ public function map(Builder $builder, mixed $results, Model $model): EloquentCollection { + /** @var Model&SearchableInterface $model */ if ($this->getTotalCount($results) === 0) { return $model->newCollection(); } @@ -520,16 +519,13 @@ public function map(Builder $builder, mixed $results, Model $model): EloquentCol /** @var array $objectIds */ $objectIdPositions = array_flip($objectIds); - /** @var EloquentCollection $scoutModels */ $scoutModels = $model->getScoutModelsByIds($builder, $objectIds); return $scoutModels ->filter(static function (Model $m) use ($objectIds): bool { - /** @var Model&SearchableInterface $m */ return in_array($m->getScoutKey(), $objectIds, false); }) ->sortBy(static function (Model $m) use ($objectIdPositions): int { - /** @var Model&SearchableInterface $m */ return $objectIdPositions[$m->getScoutKey()]; }) ->values(); @@ -537,11 +533,10 @@ public function map(Builder $builder, mixed $results, Model $model): EloquentCol /** * Map the given results to instances of the given model via a lazy collection. - * - * @param Model&SearchableInterface $model */ public function lazyMap(Builder $builder, mixed $results, Model $model): LazyCollection { + /** @var Model&SearchableInterface $model */ if ((int) ($results['found'] ?? 0) === 0) { return LazyCollection::empty(); } @@ -557,11 +552,9 @@ public function lazyMap(Builder $builder, mixed $results, Model $model): LazyCol return $model->queryScoutModelsByIds($builder, $objectIds) ->cursor() ->filter(static function (Model $m) use ($objectIds): bool { - /** @var Model&SearchableInterface $m */ return in_array($m->getScoutKey(), $objectIds, false); }) ->sortBy(static function (Model $m) use ($objectIdPositions): int { - /** @var Model&SearchableInterface $m */ return $objectIdPositions[$m->getScoutKey()]; }) ->values(); @@ -578,11 +571,11 @@ public function getTotalCount(mixed $results): int /** * Flush all of the model's records from the engine. * - * @param Model&SearchableInterface $model * @throws TypesenseClientError */ public function flush(Model $model): void { + /** @var Model&SearchableInterface $model */ try { $this->collection($model->indexableAs())->delete(); } catch (ObjectNotFound) { diff --git a/src/scout/src/Events/ModelsFlushed.php b/src/scout/src/Events/ModelsFlushed.php index 8552389e3..c33df7866 100644 --- a/src/scout/src/Events/ModelsFlushed.php +++ b/src/scout/src/Events/ModelsFlushed.php @@ -6,12 +6,11 @@ use Hypervel\Database\Eloquent\Collection; use Hypervel\Database\Eloquent\Model; -use Hypervel\Scout\Contracts\SearchableInterface; /** * Event fired when models are flushed from the search index. * - * @template TModel of Model&SearchableInterface + * @template TModel of Model */ class ModelsFlushed { diff --git a/src/scout/src/Events/ModelsImported.php b/src/scout/src/Events/ModelsImported.php index 5bad87985..ff9754382 100644 --- a/src/scout/src/Events/ModelsImported.php +++ b/src/scout/src/Events/ModelsImported.php @@ -6,12 +6,11 @@ use Hypervel\Database\Eloquent\Collection; use Hypervel\Database\Eloquent\Model; -use Hypervel\Scout\Contracts\SearchableInterface; /** * Event fired when models are imported to the search index. * - * @template TModel of Model&SearchableInterface + * @template TModel of Model */ class ModelsImported { diff --git a/src/scout/src/Jobs/MakeSearchable.php b/src/scout/src/Jobs/MakeSearchable.php index c6d931a04..baa7731b4 100644 --- a/src/scout/src/Jobs/MakeSearchable.php +++ b/src/scout/src/Jobs/MakeSearchable.php @@ -22,7 +22,7 @@ class MakeSearchable implements ShouldQueue /** * Create a new job instance. * - * @param Collection $models + * @param Collection $models */ public function __construct( public Collection $models diff --git a/src/scout/src/Jobs/RemoveFromSearch.php b/src/scout/src/Jobs/RemoveFromSearch.php index b9c371d2a..4ee382b24 100644 --- a/src/scout/src/Jobs/RemoveFromSearch.php +++ b/src/scout/src/Jobs/RemoveFromSearch.php @@ -28,7 +28,7 @@ class RemoveFromSearch implements ShouldQueue /** * Create a new job instance. * - * @param Collection $models + * @param Collection $models */ public function __construct(Collection $models) { diff --git a/src/scout/src/Jobs/RemoveableScoutCollection.php b/src/scout/src/Jobs/RemoveableScoutCollection.php index 42617fdb8..915aca2b9 100644 --- a/src/scout/src/Jobs/RemoveableScoutCollection.php +++ b/src/scout/src/Jobs/RemoveableScoutCollection.php @@ -16,7 +16,7 @@ * rather than their database IDs, as the models may already be deleted. * * @template TKey of array-key - * @template TModel of Model&SearchableInterface + * @template TModel of Model * @extends Collection */ class RemoveableScoutCollection extends Collection @@ -34,8 +34,11 @@ public function getQueueableIds(): array $first = $this->first(); - if (in_array(Searchable::class, class_uses_recursive($first))) { - return $this->map(fn (SearchableInterface $model) => $model->getScoutKey())->all(); + if (in_array(Searchable::class, class_uses_recursive($first), true)) { + return $this->map(function (Model $model) { + /** @var Model&SearchableInterface $model */ + return $model->getScoutKey(); + })->all(); } return parent::getQueueableIds(); diff --git a/src/scout/src/ModelObserver.php b/src/scout/src/ModelObserver.php index e5205c3b5..257496ebe 100644 --- a/src/scout/src/ModelObserver.php +++ b/src/scout/src/ModelObserver.php @@ -189,6 +189,6 @@ protected function whileForcingUpdate(Closure $callback): mixed */ protected function usesSoftDelete(Model $model): bool { - return in_array(SoftDeletes::class, class_uses_recursive($model)); + return in_array(SoftDeletes::class, class_uses_recursive($model), true); } } diff --git a/src/scout/src/Scout.php b/src/scout/src/Scout.php index 5445303e1..30716183c 100644 --- a/src/scout/src/Scout.php +++ b/src/scout/src/Scout.php @@ -8,7 +8,6 @@ use Hypervel\Context\CoroutineContext; use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Database\Eloquent\Model; -use Hypervel\Scout\Contracts\SearchableInterface; use Hypervel\Scout\Engines\Engine; use Hypervel\Scout\Jobs\MakeSearchable; use Hypervel\Scout\Jobs\RemoveFromSearch; @@ -262,7 +261,7 @@ public static function isImporting(): bool /** * Run the given callback with an import progress reporter in the current coroutine. * - * @param callable(EloquentCollection): void $reporter + * @param callable(EloquentCollection): void $reporter */ public static function whileReportingImportProgress(callable $reporter, callable $callback): mixed { @@ -285,7 +284,7 @@ public static function whileReportingImportProgress(callable $reporter, callable /** * Report imported models to the current coroutine's scout:import progress reporter. * - * @param EloquentCollection $models + * @param EloquentCollection $models */ public static function reportImportProgress(EloquentCollection $models): void { diff --git a/src/scout/src/Searchable.php b/src/scout/src/Searchable.php index c8a9e1e2d..0a4cea816 100644 --- a/src/scout/src/Searchable.php +++ b/src/scout/src/Searchable.php @@ -350,6 +350,9 @@ public function wasSearchableBeforeDelete(): bool /** * Get the requested models from an array of object IDs. + * + * @param array $ids + * @return Collection */ public function getScoutModelsByIds(Builder $builder, array $ids): Collection { @@ -358,6 +361,9 @@ public function getScoutModelsByIds(Builder $builder, array $ids): Collection /** * Get a query builder for retrieving the requested models from an array of object IDs. + * + * @param array $ids + * @return EloquentBuilder */ public function queryScoutModelsByIds(Builder $builder, array $ids): EloquentBuilder { @@ -369,7 +375,7 @@ public function queryScoutModelsByIds(Builder $builder, array $ids): EloquentBui call_user_func($builder->queryCallback, $query); } - $whereIn = in_array($this->getScoutKeyType(), ['int', 'integer']) + $whereIn = in_array($this->getScoutKeyType(), ['int', 'integer'], true) ? 'whereIntegerInRaw' : 'whereIn'; @@ -578,7 +584,7 @@ public static function waitForSearchableJobs(): void */ protected static function usesSoftDelete(): bool { - return in_array(SoftDeletes::class, class_uses_recursive(static::class)); + return in_array(SoftDeletes::class, class_uses_recursive(static::class), true); } /** diff --git a/src/scout/src/SearchableScope.php b/src/scout/src/SearchableScope.php index 6fec11a27..bbbc52dd6 100644 --- a/src/scout/src/SearchableScope.php +++ b/src/scout/src/SearchableScope.php @@ -40,9 +40,14 @@ public function extend(EloquentBuilder $builder): void $chunkSize = $chunk ?? config('scout.chunk.searchable', 500); $builder->chunkById($chunkSize, function (Collection $models) { - /** @var EloquentCollection $models */ - /* @phpstan-ignore method.notFound (searchable() added via Searchable trait) */ - $models->filter(fn ($m) => $m->shouldBeSearchable())->searchable(); + /** @var EloquentCollection $models */ + $searchableModels = $models->filter(function (Model $model): bool { + /** @var Model&SearchableInterface $model */ + return $model->shouldBeSearchable(); + }); + + /* @phpstan-ignore method.notFound (searchable() macro is registered by the Searchable trait) */ + $searchableModels->searchable(); // @phpstan-ignore staticMethod.notFound (local macros retain their lexical class scope at runtime) static::dispatchEvent(ModelsImported::class, $models); @@ -57,8 +62,8 @@ public function extend(EloquentBuilder $builder): void $chunkSize = $chunk ?? config('scout.chunk.unsearchable', 500); $builder->chunkById($chunkSize, function (Collection $models) { - /** @var EloquentCollection $models */ - /* @phpstan-ignore method.notFound (unsearchable() added via Searchable trait) */ + /** @var EloquentCollection $models */ + /* @phpstan-ignore method.notFound (unsearchable() macro is registered by the Searchable trait) */ $models->unsearchable(); // @phpstan-ignore staticMethod.notFound (local macros retain their lexical class scope at runtime) diff --git a/src/scout/src/Traits/UniqueByScoutKeys.php b/src/scout/src/Traits/UniqueByScoutKeys.php index 8863efc0b..e467a6312 100644 --- a/src/scout/src/Traits/UniqueByScoutKeys.php +++ b/src/scout/src/Traits/UniqueByScoutKeys.php @@ -23,7 +23,8 @@ public function uniqueId(): string { return hash('sha256', json_encode([ $this->models->getQueueableClass(), - $this->models->map(function (Model&SearchableInterface $model) { + $this->models->map(function (Model $model) { + /** @var Model&SearchableInterface $model */ return $model->getScoutKey(); })->sort()->values()->all(), ], JSON_THROW_ON_ERROR)); From bab7e287a1f3a7ba7ef122552b285899544390fe Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:29:25 +0000 Subject: [PATCH 2/4] Cover trait-only Scout model usage Convert Scout's application-model fixtures to use the Searchable trait without implementing the internal interface. Update engine mocks and scope/coroutine coverage around the widened model boundaries. Keep the Typesense removable-collection regression discriminating by mocking the stored key separately from the model's live Scout key. --- tests/Scout/Feature/CoroutineSafetyTest.php | 3 +-- tests/Scout/Feature/SearchableScopeTest.php | 3 +-- tests/Scout/Models/ConditionalSearchableModel.php | 3 +-- tests/Scout/Models/ConfigBasedTypesenseModel.php | 3 +-- tests/Scout/Models/CustomScoutKeyModel.php | 3 +-- tests/Scout/Models/FilteringSearchableModel.php | 3 +-- tests/Scout/Models/PrefixSearchableModel.php | 3 +-- tests/Scout/Models/SearchableModel.php | 3 +-- tests/Scout/Models/SoftDeletableSearchableModel.php | 3 +-- tests/Scout/Models/SoftDeleteSearchableModel.php | 3 +-- tests/Scout/Models/TypesenseSearchableModel.php | 3 +-- tests/Scout/Models/TypesenseSoftDeleteSearchableModel.php | 3 +-- tests/Scout/Models/UuidSearchableModel.php | 3 +-- tests/Scout/Unit/Engines/AlgoliaEngineTest.php | 4 ++-- tests/Scout/Unit/Engines/MeilisearchEngineTest.php | 4 ++-- tests/Scout/Unit/Engines/TypesenseEngineTest.php | 6 ++++-- 16 files changed, 21 insertions(+), 32 deletions(-) diff --git a/tests/Scout/Feature/CoroutineSafetyTest.php b/tests/Scout/Feature/CoroutineSafetyTest.php index f008a2081..db546da62 100644 --- a/tests/Scout/Feature/CoroutineSafetyTest.php +++ b/tests/Scout/Feature/CoroutineSafetyTest.php @@ -8,7 +8,6 @@ use Hypervel\Coroutine\WaitGroup; use Hypervel\Database\Eloquent\Collection; use Hypervel\Database\Eloquent\Model; -use Hypervel\Scout\Contracts\SearchableInterface; use Hypervel\Scout\Jobs\MakeSearchable; use Hypervel\Scout\Jobs\RemoveFromSearch; use Hypervel\Scout\ModelObserver; @@ -485,7 +484,7 @@ public function testWhileImportingIsNestingSafe(): void } } -class ForceSavingSearchableModel extends Model implements SearchableInterface +class ForceSavingSearchableModel extends Model { use Searchable; diff --git a/tests/Scout/Feature/SearchableScopeTest.php b/tests/Scout/Feature/SearchableScopeTest.php index 84b598a60..190bd340b 100644 --- a/tests/Scout/Feature/SearchableScopeTest.php +++ b/tests/Scout/Feature/SearchableScopeTest.php @@ -7,7 +7,6 @@ use Hypervel\Database\Eloquent\Collection; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\Relations\HasManyThrough; -use Hypervel\Scout\Contracts\SearchableInterface; use Hypervel\Scout\Events\ModelsFlushed; use Hypervel\Scout\Events\ModelsImported; use Hypervel\Scout\Scout; @@ -236,7 +235,7 @@ class ScoutThroughIntermediate extends Model protected array $guarded = []; } -class ScoutThroughSearchableModel extends Model implements SearchableInterface +class ScoutThroughSearchableModel extends Model { use Searchable; diff --git a/tests/Scout/Models/ConditionalSearchableModel.php b/tests/Scout/Models/ConditionalSearchableModel.php index 0e2e597eb..03b620437 100644 --- a/tests/Scout/Models/ConditionalSearchableModel.php +++ b/tests/Scout/Models/ConditionalSearchableModel.php @@ -5,13 +5,12 @@ namespace Hypervel\Tests\Scout\Models; use Hypervel\Database\Eloquent\Model; -use Hypervel\Scout\Contracts\SearchableInterface; use Hypervel\Scout\Searchable; /** * Test model that uses shouldBeSearchable() for conditional indexing. */ -class ConditionalSearchableModel extends Model implements SearchableInterface +class ConditionalSearchableModel extends Model { use Searchable; diff --git a/tests/Scout/Models/ConfigBasedTypesenseModel.php b/tests/Scout/Models/ConfigBasedTypesenseModel.php index a1f7aad2a..7e86167d3 100644 --- a/tests/Scout/Models/ConfigBasedTypesenseModel.php +++ b/tests/Scout/Models/ConfigBasedTypesenseModel.php @@ -5,7 +5,6 @@ namespace Hypervel\Tests\Scout\Models; use Hypervel\Database\Eloquent\Model; -use Hypervel\Scout\Contracts\SearchableInterface; use Hypervel\Scout\Searchable; /** @@ -14,7 +13,7 @@ * This model does NOT define typesenseCollectionSchema() or typesenseSearchParameters(), * so the engine must read settings from config. */ -class ConfigBasedTypesenseModel extends Model implements SearchableInterface +class ConfigBasedTypesenseModel extends Model { use Searchable; diff --git a/tests/Scout/Models/CustomScoutKeyModel.php b/tests/Scout/Models/CustomScoutKeyModel.php index 791099c08..11d28c42f 100644 --- a/tests/Scout/Models/CustomScoutKeyModel.php +++ b/tests/Scout/Models/CustomScoutKeyModel.php @@ -5,13 +5,12 @@ namespace Hypervel\Tests\Scout\Models; use Hypervel\Database\Eloquent\Model; -use Hypervel\Scout\Contracts\SearchableInterface; use Hypervel\Scout\Searchable; /** * Test model with a custom Scout key. */ -class CustomScoutKeyModel extends Model implements SearchableInterface +class CustomScoutKeyModel extends Model { use Searchable; diff --git a/tests/Scout/Models/FilteringSearchableModel.php b/tests/Scout/Models/FilteringSearchableModel.php index a57677beb..2c00edab6 100644 --- a/tests/Scout/Models/FilteringSearchableModel.php +++ b/tests/Scout/Models/FilteringSearchableModel.php @@ -6,13 +6,12 @@ use Hypervel\Database\Eloquent\Collection; use Hypervel\Database\Eloquent\Model; -use Hypervel\Scout\Contracts\SearchableInterface; use Hypervel\Scout\Searchable; /** * Test model that filters models in makeSearchableUsing(). */ -class FilteringSearchableModel extends Model implements SearchableInterface +class FilteringSearchableModel extends Model { use Searchable; diff --git a/tests/Scout/Models/PrefixSearchableModel.php b/tests/Scout/Models/PrefixSearchableModel.php index d7dd8191d..a8418443d 100644 --- a/tests/Scout/Models/PrefixSearchableModel.php +++ b/tests/Scout/Models/PrefixSearchableModel.php @@ -6,13 +6,12 @@ use Hypervel\Database\Eloquent\Model; use Hypervel\Scout\Attributes\SearchUsingPrefix; -use Hypervel\Scout\Contracts\SearchableInterface; use Hypervel\Scout\Searchable; /** * Test model that uses prefix search on the title column. */ -class PrefixSearchableModel extends Model implements SearchableInterface +class PrefixSearchableModel extends Model { use Searchable; diff --git a/tests/Scout/Models/SearchableModel.php b/tests/Scout/Models/SearchableModel.php index d322c4b76..54491f6db 100644 --- a/tests/Scout/Models/SearchableModel.php +++ b/tests/Scout/Models/SearchableModel.php @@ -5,13 +5,12 @@ namespace Hypervel\Tests\Scout\Models; use Hypervel\Database\Eloquent\Model; -use Hypervel\Scout\Contracts\SearchableInterface; use Hypervel\Scout\Searchable; /** * Test model for Scout tests. */ -class SearchableModel extends Model implements SearchableInterface +class SearchableModel extends Model { use Searchable; diff --git a/tests/Scout/Models/SoftDeletableSearchableModel.php b/tests/Scout/Models/SoftDeletableSearchableModel.php index 971f1b015..862dcec98 100644 --- a/tests/Scout/Models/SoftDeletableSearchableModel.php +++ b/tests/Scout/Models/SoftDeletableSearchableModel.php @@ -6,13 +6,12 @@ use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\SoftDeletes; -use Hypervel\Scout\Contracts\SearchableInterface; use Hypervel\Scout\Searchable; /** * Test model with soft deletes for Scout tests. */ -class SoftDeletableSearchableModel extends Model implements SearchableInterface +class SoftDeletableSearchableModel extends Model { use Searchable; use SoftDeletes; diff --git a/tests/Scout/Models/SoftDeleteSearchableModel.php b/tests/Scout/Models/SoftDeleteSearchableModel.php index 0baeea8bc..8569f98a3 100644 --- a/tests/Scout/Models/SoftDeleteSearchableModel.php +++ b/tests/Scout/Models/SoftDeleteSearchableModel.php @@ -6,13 +6,12 @@ use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\SoftDeletes; -use Hypervel\Scout\Contracts\SearchableInterface; use Hypervel\Scout\Searchable; /** * Test model for Scout soft delete integration tests. */ -class SoftDeleteSearchableModel extends Model implements SearchableInterface +class SoftDeleteSearchableModel extends Model { use Searchable; use SoftDeletes; diff --git a/tests/Scout/Models/TypesenseSearchableModel.php b/tests/Scout/Models/TypesenseSearchableModel.php index e1fa3b79b..bb41f5baf 100644 --- a/tests/Scout/Models/TypesenseSearchableModel.php +++ b/tests/Scout/Models/TypesenseSearchableModel.php @@ -5,7 +5,6 @@ namespace Hypervel\Tests\Scout\Models; use Hypervel\Database\Eloquent\Model; -use Hypervel\Scout\Contracts\SearchableInterface; use Hypervel\Scout\Searchable; /** @@ -13,7 +12,7 @@ * * Includes typesenseCollectionSchema() for proper schema definition. */ -class TypesenseSearchableModel extends Model implements SearchableInterface +class TypesenseSearchableModel extends Model { use Searchable; diff --git a/tests/Scout/Models/TypesenseSoftDeleteSearchableModel.php b/tests/Scout/Models/TypesenseSoftDeleteSearchableModel.php index e1d9c6c4d..703972c20 100644 --- a/tests/Scout/Models/TypesenseSoftDeleteSearchableModel.php +++ b/tests/Scout/Models/TypesenseSoftDeleteSearchableModel.php @@ -6,7 +6,6 @@ use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\SoftDeletes; -use Hypervel\Scout\Contracts\SearchableInterface; use Hypervel\Scout\Searchable; /** @@ -14,7 +13,7 @@ * * Includes typesenseCollectionSchema() with __soft_deleted field. */ -class TypesenseSoftDeleteSearchableModel extends Model implements SearchableInterface +class TypesenseSoftDeleteSearchableModel extends Model { use Searchable; use SoftDeletes; diff --git a/tests/Scout/Models/UuidSearchableModel.php b/tests/Scout/Models/UuidSearchableModel.php index 6df8e8f59..0abc3282e 100644 --- a/tests/Scout/Models/UuidSearchableModel.php +++ b/tests/Scout/Models/UuidSearchableModel.php @@ -6,13 +6,12 @@ use Hypervel\Database\Eloquent\Concerns\HasUuids; use Hypervel\Database\Eloquent\Model; -use Hypervel\Scout\Contracts\SearchableInterface; use Hypervel\Scout\Searchable; /** * UUID7-keyed test fixture for the string-key path in scout:queue-import. */ -class UuidSearchableModel extends Model implements SearchableInterface +class UuidSearchableModel extends Model { use HasUuids; use Searchable; diff --git a/tests/Scout/Unit/Engines/AlgoliaEngineTest.php b/tests/Scout/Unit/Engines/AlgoliaEngineTest.php index 4f617e337..e26180c96 100644 --- a/tests/Scout/Unit/Engines/AlgoliaEngineTest.php +++ b/tests/Scout/Unit/Engines/AlgoliaEngineTest.php @@ -1300,7 +1300,7 @@ protected function createSoftDeleteSearchableModelMock(): m\MockInterface /** * Test model for AlgoliaEngine tests. */ -class AlgoliaTestSearchableModel extends Model implements SearchableInterface +class AlgoliaTestSearchableModel extends Model { use Searchable; @@ -1312,7 +1312,7 @@ class AlgoliaTestSearchableModel extends Model implements SearchableInterface /** * Test model with soft deletes for AlgoliaEngine tests. */ -class AlgoliaTestSoftDeleteModel extends Model implements SearchableInterface +class AlgoliaTestSoftDeleteModel extends Model { use Searchable; use SoftDeletes; diff --git a/tests/Scout/Unit/Engines/MeilisearchEngineTest.php b/tests/Scout/Unit/Engines/MeilisearchEngineTest.php index b3b92f68f..e80b469c6 100644 --- a/tests/Scout/Unit/Engines/MeilisearchEngineTest.php +++ b/tests/Scout/Unit/Engines/MeilisearchEngineTest.php @@ -1145,7 +1145,7 @@ protected function createSoftDeleteSearchableModelMock(): m\MockInterface /** * Test model for MeilisearchEngine tests. */ -class MeilisearchTestSearchableModel extends Model implements SearchableInterface +class MeilisearchTestSearchableModel extends Model { use Searchable; @@ -1157,7 +1157,7 @@ class MeilisearchTestSearchableModel extends Model implements SearchableInterfac /** * Test model with soft deletes for MeilisearchEngine tests. */ -class MeilisearchTestSoftDeleteModel extends Model implements SearchableInterface +class MeilisearchTestSoftDeleteModel extends Model { use Searchable; use SoftDeletes; diff --git a/tests/Scout/Unit/Engines/TypesenseEngineTest.php b/tests/Scout/Unit/Engines/TypesenseEngineTest.php index af8738ed7..6fb832d5e 100644 --- a/tests/Scout/Unit/Engines/TypesenseEngineTest.php +++ b/tests/Scout/Unit/Engines/TypesenseEngineTest.php @@ -470,8 +470,10 @@ public function testDeleteUsesStoredScoutKeyFromRemoveableCollection(): void $client = m::mock(TypesenseClient::class); $client->shouldReceive('getCollections')->once()->andReturn($collections); - $model = new TypesenseLifecycleModel; - $model->setRawAttributes(['scout_id' => 'stored-scout-key']); + $model = $this->createSearchableModelMock(); + $model->shouldReceive('getScoutKeyName')->andReturn('scout_id'); + $model->shouldReceive('getAttribute')->with('scout_id')->andReturn('stored-scout-key'); + $model->shouldReceive('indexableAs')->andReturn('write_index'); $this->createEngine($client)->delete(new RemoveableScoutCollection([$model])); } From 833d7d0d3240495a180555d1d0bf45b249dda2d7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:29:36 +0000 Subject: [PATCH 3/4] Document trait-only Scout models Update the Scout and search documentation to show the Laravel-style model setup: extend Eloquent Model and use the Searchable trait. Remove the internal interface from application examples and explain that the trait supplies the complete model capability used by Scout. --- src/boost/docs/scout.md | 17 ++++++----------- src/boost/docs/search.md | 5 ++--- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/src/boost/docs/scout.md b/src/boost/docs/scout.md index 6cf1d6474..142f05d45 100644 --- a/src/boost/docs/scout.md +++ b/src/boost/docs/scout.md @@ -57,7 +57,7 @@ After installing Scout, you should publish the Scout configuration file using th php artisan vendor:publish --tag=scout-config ``` -Finally, add the `Hypervel\Scout\Searchable` trait and the `Hypervel\Scout\Contracts\SearchableInterface` contract to the model you would like to make searchable. The trait will register a model observer that will automatically keep the model in sync with your search driver: +Finally, add the `Hypervel\Scout\Searchable` trait to the model you would like to make searchable. The trait will register a model observer that will automatically keep the model in sync with your search driver: ```php ### Database Engine -Scout's built-in database engine performs full-text and `LIKE` searches against your existing database — no external service or extra infrastructure required. Simply add the `Searchable` trait to your model, implement the `SearchableInterface` contract, and define a `toSearchableArray` method that returns the columns you want to be searchable. +Scout's built-in database engine performs full-text and `LIKE` searches against your existing database — no external service or extra infrastructure required. Simply add the `Searchable` trait to your model and define a `toSearchableArray` method that returns the columns you want to be searchable. You may use PHP attributes to control the search strategy for each column. `SearchUsingFullText` will use your database's full-text index, `SearchUsingPrefix` will only match from the beginning of the string (`example%`), and any columns without an attribute use a default `LIKE` strategy with wildcards on both sides (`%example%`): @@ -168,10 +168,9 @@ namespace App\Models; use Hypervel\Database\Eloquent\Model; use Hypervel\Scout\Attributes\SearchUsingFullText; use Hypervel\Scout\Attributes\SearchUsingPrefix; -use Hypervel\Scout\Contracts\SearchableInterface; use Hypervel\Scout\Searchable; -class Article extends Model implements SearchableInterface +class Article extends Model { use Searchable; From 4fba7a0a08e540981ef5b5093ab94338f1b356d1 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:41:32 +0000 Subject: [PATCH 4/4] Use the imported model in Scout annotations Reference the existing Model import in the SearchableInterface PHPStan requirement. This keeps the internal contract consistent with repository import conventions without changing its behavior or type meaning. --- src/scout/src/Contracts/SearchableInterface.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scout/src/Contracts/SearchableInterface.php b/src/scout/src/Contracts/SearchableInterface.php index 8ed44bddc..2f15901a1 100644 --- a/src/scout/src/Contracts/SearchableInterface.php +++ b/src/scout/src/Contracts/SearchableInterface.php @@ -18,7 +18,7 @@ * not need to implement this interface directly. Keep this shape compatible * with the trait while widening collection boundaries to Model for engines. * - * @phpstan-require-extends \Hypervel\Database\Eloquent\Model + * @phpstan-require-extends Model */ interface SearchableInterface {