feat: Scout: Algolia v4 driver#373
Merged
binaryfire merged 26 commits into0.4from Apr 22, 2026
Merged
Conversation
Adds the v4 Algolia client as a require-dev so CI and local dev can run the new Algolia engine tests. Mirrors the existing meilisearch/meilisearch-php and typesense/typesense-php require-dev entries — root declares the package for testing; the scout sub-package declares it in suggest so downstream consumers see the driver is optional.
Adds 'algolia' to the package keywords and an 'algolia/algoliasearch-client-php (^4.0)' entry to the suggest block, advertising the driver without making the SDK a hard dependency.
Mirrors Laravel Scout's config shape: - identify: when true, the Algolia engine sends per-request X-Forwarded-For and X-Algolia-UserToken headers for analytics (default false). - algolia: id/secret credentials plus an index-settings block for scout:sync-index-settings. Also extends the supported-drivers comment to list "algolia".
Single concrete class built on the Algolia v4 SearchClient. Laravel's v3/v4 abstract+concrete split is collapsed because Hypervel only supports v4 (v3 is EOL and the porting guide excludes deprecated upstream code). Deliberate divergence from Laravel: identify headers (X-Forwarded-For, X-Algolia-UserToken) are computed per search call from the current RequestContext and passed via the searchSingleIndex \$requestOptions['headers'] parameter. Laravel's EngineManager bakes them into SearchConfig::setDefaultHeaders at driver-creation time, which is correct under shared-nothing but broken under Hypervel's persistent worker (the engine is cached across requests). identifyHeaders() reads RequestContext::getOrNull() directly rather than via the request() helper. Matches the CookieJar pattern, works without HttpServiceProvider being registered, and expresses the intent "only identify when a real request is in context" cleanly. Also adds deleteAllIndexes(?string \$prefix = null) for parity with the Hypervel MeilisearchEngine: when a prefix is supplied, only indexes whose name starts with it are deleted. Without a prefix, every index in the Algolia application is removed.
Adds createAlgoliaDriver() plus ensureAlgoliaClientIsInstalled() — the installed-SDK guard mirrors the existing Meilisearch/Typesense patterns. Identify config (scout.identify) is read here and injected as a constructor argument on the AlgoliaEngine, so the engine stays container-free while the driver resolution stays declarative.
Two changes to register(): 1. New Algolia4SearchClient singleton. The factory closure pins the Algolia SDK's HTTP client to Guzzle explicitly via Algolia::setHttpClient(new GuzzleHttpClient(new GuzzleClient)) rather than relying on Algolia::getHttpClient()'s internal auto-decide heuristic — that heuristic is implementation detail and can change under ^4.0 minor releases (swap to PSR-18 discovery, reorder Guzzle detection, Symfony HttpClient preference) with no semver signal. The closure also registers the Hypervel Scout user-agent segment and honours optional connect/read/write timeout config keys. 2. Meilisearch binding now passes an explicit 'new GuzzleClient' as the 3rd constructor argument. Previously the binding fell through to Psr18ClientDiscovery::find() which picks whatever PSR-18 implementation php-http/discovery resolves — in worst case Symfony's CurlHttpClient, which is Swoole-unsafe. Mirrors the defensive pattern already used in the Typesense binding.
Changes the signature from deleteAllIndexes(): array to deleteAllIndexes(?string \$prefix = null): array. When a prefix is supplied, only indexes whose uid starts with it are deleted. Without a prefix, every index on the server is removed — matching the pre-existing behaviour. Closes a real footgun: on a shared Meilisearch instance (Meilisearch Cloud, multi-env, multi-app), an unscoped call would nuke indexes owned by other applications. The scout:delete-all-indexes command now passes scout.prefix to this method (with an empty-prefix safety gate) to make the scoped behaviour the default. The \$uid === null guard is necessary because Meilisearch\Endpoints\Indexes::getUid() has a declared return type of ?string and str_starts_with throws TypeError on null under strict types.
Adds a --force option and reorders the handler so the safety check runs before engine resolution. When scout.prefix is empty and --force is not passed, the command aborts with an actionable error message — without instantiating the driver's underlying client (avoiding Algolia::setHttpClient mutation or the missing-SDK check firing before the user sees the safety message). When the prefix is set (or --force is passed), the command forwards scope to MeilisearchEngine::deleteAllIndexes / AlgoliaEngine::deleteAllIndexes via the new ?string \$prefix parameter. An empty string is normalised to null at the boundary so the engine's null-check path handles both consistently. Repository is method-injected on handle(), matching the precedent set by SyncIndexSettingsCommand.
Mirrors the shape of InteractsWithTypesense and InteractsWithMeilisearch: skip on missing credentials (default-unavailable path), fail loudly on any probe error when credentials ARE explicit (misconfiguration). Unlike the other two, Algolia has no local-default equivalent — there is no "127.0.0.1 on port X" default to fall back to, so the skip path is "no ALGOLIA_APP_ID / ALGOLIA_SECRET set". Any failure after credentials are present is a real problem and propagates. Parallel-safe via TEST_TOKEN prefixing. Cleanup iterates listIndices and deletes every index whose name starts with the test prefix.
Generic base for Algolia integration tests (non-Scout-specific). Uses the InteractsWithAlgolia trait for auto-skip and cleanup. Follows the same shape as TypesenseIntegrationTestCase and MeilisearchIntegrationTestCase.
Scout-flavoured extension of AlgoliaIntegrationTestCase: RefreshDatabase trait, Scout command registration, engine initialisation in setUpInCoroutine, and cleanup in tearDownInCoroutine. Mirrors MeilisearchScoutIntegrationTestCase and TypesenseScoutIntegrationTestCase.
Adds the commented ALGOLIA_APP_ID / ALGOLIA_SECRET block below the existing Typesense entries so developers running integration tests locally know what to set. Commented by default — Algolia is cloud-only with no sensible local default.
Adds a third job ('algolia') after 'typesense' in the scout workflow.
Because Algolia is cloud-only, the job has no sidecar services
block — instead it checks whether ALGOLIA_APP_ID and ALGOLIA_SECRET
repository secrets are configured, and only runs the integration
tests when they are. Forks without the secrets see a clear skip note
instead of a red build.
42 tests exercising every public method plus the deliberate identify divergence and the new deleteAllIndexes scoping: - update / delete / search / paginate / flush / deleteIndex / updateIndexSettings / createIndex / mapIds / map / lazyMap / getTotalCount / configureSoftDeleteFilter / __call - identify disabled / enabled-with-IP / enabled-with-user-token / private-IP filtering - identify multi-request isolation within a single test coroutine (RequestContext::set / forget / set again) - identify cross-coroutine isolation via two go() coroutines each seeding their own request - soft-delete combined with empty searchable array (regression guard for the Laravel Feature-test case) - deleteAllIndexes scoping (prefix / null / empty-string / no indexes / malformed entries / return-value shape) Fixtures: AlgoliaTestSearchableModel + AlgoliaTestSoftDeleteModel use the Searchable trait because they're only Mockery-mocked (never instantiated — trait boot is never triggered). AlgoliaTestChirpModel is a plain Eloquent Model WITHOUT the Searchable trait so it can be instantiated in a bare unit test — bootSearchable() would instantiate ModelObserver which needs a facade root the unit TestCase doesn't provide. AlgoliaTestUser is a real class rather than a Mockery mock because method_exists cannot see Mockery's __call method stubs.
Four tests for the new ?string \$prefix parameter: - 'test_' prefix deletes only test_-prefixed uids, preserves others - null prefix deletes every index (pre-existing unscoped behaviour) - empty-string prefix deletes every index (str_starts_with semantics) - return value is the array of task objects from each \$index->delete() Mocks Meilisearch\Endpoints\Indexes with getUid / delete expectations; wraps them in an IndexesResults mock returned by getIndexes(IndexesQuery).
The three pre-existing tests needed the new handle(EngineManager, Repository) signature plus a non-empty scout.prefix on the Repository mock — otherwise the new safety gate fires before reaching the engine-support check, making testFailsWhenEngineDoesNotSupportDeleteAllIndexes unreachable by its original setup. Four new tests cover the safety gate itself: - Refuses with actionable error when prefix is empty and not forced (engine is never resolved, deleteAllIndexes is never called) - Runs unscoped (prefix null) when forced with empty prefix - Runs scoped when prefix is set and --force is not passed - --force does not override scoping: non-empty prefix stays in effect
Three tests plus a createMockContainerWithAlgolia helper: - Resolves an AlgoliaEngine instance - scout.soft_delete=true is wired to the engine's protected property - scout.identify=true is wired to the engine's protected property The property assertions use ClassInvoker (matching the pattern in tests/Pool/HeartbeatConnectionTest and tests/WebSocketServer/ServerTest) rather than raw ReflectionProperty. The new helper stubs scout.identify alongside scout.driver and scout.soft_delete, mirroring createMockContainerWithTypesense.
Asserts scout.identify defaults to false and scout.algolia contains id / secret / index-settings keys by requiring src/scout/config/scout.php directly. Deliberately does not extend ScoutTestCase — that base class's setUp replaces the entire scout config array with a minimal fixture, so a shape test there would assert against the fixture rather than the package defaults shipped in the config file.
Three tests pinning the HTTP-client wiring done by the service provider: - MeilisearchClient's inner PSR-18 client is a GuzzleHttp\Client. Reflection traverses Meilisearch\Client → http (MeilisearchClientAdapter) → http (PSR-18 client). Without this binding, the Meilisearch SDK falls through to Psr18ClientDiscovery::find() which may resolve to a Swoole-unsafe implementation (e.g. Symfony's CurlHttpClient). - Algolia4SearchClient resolves from the container using fake credentials seeded into scout.algolia.id / scout.algolia.secret. - Algolia::getHttpClient() returns the GuzzleHttpClient wrapper set by the binding closure, and its inner Guzzle instance is a real GuzzleHttp\Client. Pins behaviour against upstream drift in Algolia::getHttpClient()'s auto-decide heuristic.
Real-wire regression test for the deleteAllIndexes scoping fix. Creates two prefixed indexes alongside one unprefixed index, calls \$engine->deleteAllIndexes(\$testPrefix), waits for Meilisearch tasks to complete, then re-lists and asserts that the unprefixed index survives while the prefixed ones are gone. Cleans up the unprefixed index in a finally block regardless of pass/fail.
Baseline connectivity checks against a live Algolia application: - listIndices returns the expected response shape - A new index can be materialised (via saveObject) and then removed Skips cleanly when ALGOLIA_APP_ID / ALGOLIA_SECRET are unset; any error with credentials present propagates (matches the trait's explicit-misconfiguration-fails-loudly discipline).
Real-wire tests for core engine operations: - update indexes models so they become searchable - update handles multiple models in one batch - delete removes the specified models - flush clears the entire index A pollSearch helper handles Algolia's eventual consistency by retrying searchSingleIndex every 200ms for up to 10s until the hit count matches the expectation.
Real-wire tests for the filter translation in AlgoliaEngine::filters(). Configures attributesForFaceting via filterOnly() before indexing so numericFilters work on id/title/body, then asserts each clause narrows the result set correctly against live data.
Real-wire test for Scout's soft-delete behaviour on the Algolia engine. With scout.soft_delete=true, creating and indexing a model pushes __soft_deleted=0 to the document; soft-deleting then re-indexing the same model pushes __soft_deleted=1. Configures attributesForFaceting with filterOnly(__soft_deleted) before the assertions run.
Real-wire tests for the Scout commands exercising the Algolia driver: - scout:import indexes models and they become searchable - scout:flush removes the indexed models - scout:delete-index drops the index from listIndices Uses the same pollSearch helper pattern as the other Algolia integration tests to handle eventual consistency.
Real-wire regression test for the identify divergence from Laravel. Seeds two different requests via RequestContext::set, runs a search after each, and captures the outgoing HTTP request headers via a PSR-3 logger plugged into the Algolia SDK through Algolia::setLogger. Asserts that each search's X-Forwarded-For reflects its own request — proving per-call header computation works end-to-end against the real API, not just against the mock.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds the Algolia driver to Scout. Also fixes a few issues with the existing Meilisearch driver.
Algolia
AlgoliaEnginefor the v4 client. Laravel has separate abstract + concrete classes because they support both v3 and v4. Since we only support v4, there's just one class.scout.identifyandscout.algolia(id, secret, index-settings). The Scout commands work with it, exceptscout:indexwhich throwsNotSupportedExceptionsince Algolia creates indexes implicitly on first write (which matches Laravel's behaviour).Identify headers are per-call, not default
Laravel's approach of setting
X-Forwarded-ForandX-Algolia-UserTokenas default headers at driver-creation time doesn't work for us.EngineManagercaches the engine, so headers from the first request leak to every subsequent one on the same worker.Moved this to per-call: compute from the current
RequestContextand pass via$requestOptions['headers']on each search call. Persistent workers stay clean.HTTP client pinning
All three drivers (Algolia, Meilisearch, Typesense) now pin Guzzle explicitly in the service provider instead of relying on SDK/default client selection. That selection could potentially land on Symfony's
CurlHttpClientor something other than Guzzle, which aren't coroutine-safe. Meilisearch was previously relying on auto-discovery, so fixed that as well. Algolia's current client selection logic is safe at the moment but there's no guarantee it'll stay that way so using an explicit client is better.scout:delete-all-indexes safety
Previously the command would silently wipe every index on the server, which is a problem on shared instances (Meilisearch Cloud, or when multiple environments share one Algolia app).
deleteAllIndexesnow take an optional$prefixparameter and only delete matching indexes.scout.prefixis empty unless a new--forceflag is passed. The error message explains the risk.This is a behaviour change but I it's an important improvement.
Tests
AlgoliaEngine(update, delete, search, map, identify, deleteAllIndexes).DeleteAllIndexesCommandTest: 3 existing tests updated for the newhandle()signature, 4 new tests for the safety gate.EngineManagerTest.ALGOLIA_APP_ID/ALGOLIA_SECRET.CI
New
algoliajob in.github/workflows/scout.yml. Gated onALGOLIA_APP_IDandALGOLIA_SECRETrepo secrets being set (Algolia is cloud-only, no this isn't something we can test wth a sidecar container).