recombee/php-api-client
PHP client for the Recombee recommendation API, installable via Composer. Send recommendation, search, and event requests; batch large data; create users/items on the fly; and fetch follow-up recommendations with recommendation IDs. Supports regional endpoints and typed request/exception classes.
Install and configure (5 minutes)
composer require recombee/php-api-client:^6.2
Add credentials to config/services.php — never instantiate the client with literals scattered through the codebase:
'recombee' => [
'database_id' => env('RECOMBEE_DATABASE_ID'),
'token' => env('RECOMBEE_PRIVATE_TOKEN'),
'region' => env('RECOMBEE_REGION', 'eu-west'), // pick the region closest to your app servers
'timeout' => env('RECOMBEE_TIMEOUT', 2), // read path: fail fast
],
Bind it once in a service provider as a singleton, alongside a thin domain service that you actually inject:
$this->app->singleton(Client::class, fn () => new Client(
config('services.recombee.database_id'),
config('services.recombee.token'),
['region' => config('services.recombee.region'), 'timeout' => config('services.recombee.timeout')]
));
$this->app->singleton(RecommendationService::class); // your wrapper, not the SDK
Where to look first
src/Requests/ in the package — this directory is the API reference. Every Recombee operation is a class (AddPurchase, SetItemValues, RecommendItemsToUser, SearchItems, Batch, …). Skim the filenames before reading any external docs.Reqs\* request and a Reqs\Batch.src/Exceptions/ — ApiException is the parent; ResponseException (bad request / 4xx) and ApiTimeoutException (network / 5xx-ish) are what you catch.First use case to ship: product/catalog sync → interaction tracking → a cached "Recommended for you" endpoint. Concretely:
recombee:sync-catalog) that pages your products table and pushes SetItemValues in batches with cascadeCreate => true.AddDetailView / AddCartAddition / AddPurchase.GET /api/recommendations controller action returning cached RecommendItemsToUser results with a curated fallback.That gets you a visible "Recommended for you" rail in a sprint, and the ingestion pipeline you'll need for everything else afterwards.
1. Abstract behind an interface — the SDK types should not leak
The single highest-value decision. Define App\Contracts\Recommender with methods shaped like your domain (similarProducts(Product $p, ?User $u, int $limit): Collection), implement it with RecombeeRecommender, and bind it in the container. Benefits: you can swap to a fake in tests, swap vendors later, and controllers never import Recombee\RecommApi\Requests. The package is vendor-maintained with a small community (~26 stars, zero dependents) — a boundary costs you an hour now and buys you an exit ramp.
2. Write path: always asynchronous, always batched
Interaction events are the highest-volume calls and the least latency-sensitive. Never send() an AddPurchase inside a checkout request.
// App\Jobs\TrackInteraction
public function handle(Recommender $recommender): void
{
$recommender->addPurchase($this->userId, $this->itemId, $this->recommId);
}
Dispatch with ->onQueue('recommendations')->afterCommit() (use afterCommit so you never push events for rolled-back transactions). For bulk imports (backfilling historical orders, nightly catalog sync) build an array of Reqs\* objects and send one Reqs\Batch. Keep batches to a few thousand requests — the API caps batch size, and large batches make partial failures coarse-grained and retries expensive. Use Bus::batch([...])->allowFailures()->onQueue('recommendations') so one bad row doesn't abort the sync, and record failures.
3. Use cascadeCreate on ingestion, but create properties first
cascadeCreate => true on AddPurchase, SetItemValues, SetUserValues means a new user or item is created on the fly rather than throwing. It does not create properties. Run AddItemProperty / AddUserProperty once, up front (in a migration-like Artisan command, or by hand), otherwise SetItemValues with an unknown property key fails.
4. Scenario-first recommendations (the biggest day-to-day win)
Rather than encoding filters and boosters in PHP, define scenarios in the Admin UI (product_detail, home_recommended, cart_cross_sell, search_top) and pass them from your code:
$client->send(new Reqs\RecommendItemsToItem($itemId, $userId, 12, ['scenario' => 'product_detail']));
Product and marketing can tune business rules without a deploy; you version them by adding new scenarios and switching the string. Reserve inline filter / booster / diversity options for genuinely dynamic, request-specific logic (e.g., "only in-stock, only this price band").
5. Pagination and attribution via recommId
RecommendItemsToUser / RecommendItemsToItem return a recommId plus the items. Two uses:
new Reqs\RecommendNextItems($recommId, 10) returns the next slice of the same ranked list — no re-ranking jitter between pages. Stash the recommId in the session or cache keyed by the page token.recommId into the follow-up AddPurchase / AddCartAddition request. This closes the loop so Recombee (and your A/B analysis) can attribute conversions to the scenario that produced them. If you're evaluating "did personalization lift revenue?", this is the mechanism — instrument it from day one, not later.6. Cache the read path, but keep it short
Recommendations are per-user and change as behaviour changes. Cache 5–15 minutes in Redis under recs:{user}:{scenario}:{limit} with a remember(); invalidate on major profile events if you care. This collapses API latency out of the page render and cuts cost. For the recommId flow, caching is fine as long as you return the cached recommId too.
7. Treat failures as a first-class state
Every call site should have a fallback. Two tiers:
ApiTimeoutException then ResponseException then ApiException; on failure serve a cached last-good response, else a cheap Eloquent fallback (where('is_bestseller') / top-sellers by 30-day sales). Never let an empty rail reach the UI; a "Popular" heading beats a hole in the layout.$tries = 3, backoff = [10, 60]), then dead-letter to a recombee_failed_interactions table or a failed-jobs dashboard. Silent loss of interaction data degrades recommendation quality over weeks in ways nobody notices.8. Keep a separate client for the read path
Read-path calls sit in the request cycle, so they should have a short timeout (1–2s). Ingestion jobs can afford 10s+. Bind Client for writes and Client::class . '.fast' (or construct inline in the read service) with a lower timeout — this one config difference prevents a slow Recombee response from eating your p95.
9. Model observers for catalog sync
Product::saved(fn (Product $p) => SyncProductToRecommender::dispatch($p->id));
Product::deleted(fn (Product $p) => RemoveProductFromRecommender::dispatch($p->id));
Send the fields the recommender actually reasons over: name/description, category, brand, price, in_stock_from, image URL — plus booleans for filters (is_active). Register AddItemProperty types deliberately (double, int, string, timestamp, image, boolean, set, tokenized); a price stored as string silently breaks numeric filters and boosters.
10. Testing
Do not hit Recombee in the test suite. Resolve Recommender to a fake in TestCase::setUp() that returns deterministic IDs; assert that controllers render the rail and that jobs dispatch. Add one or two integration tests behind an env flag (RECOMBEE_INTEGRATION=true) that hit a dedicated scratch database — mainly for the ingestion command and property schema. Use ResetDatabase in those tests, guarded.
IDs must be strings — and PHP will happily hand you integers. Recombee entities are keyed by string IDs; 42 and "42" are not guaranteed to be the same entity, and integer PKs passed straight through create silent duplicates that split interaction signal. Cast explicitly: (string) $product->id, and be consistent about prefixes (item-42, user-42) — prefixes make it impossible to confuse a user ID with an item ID in a filter or a log.
ResetDatabase is one autocomplete away from disaster. It's in the same Reqs namespace as everything else. Guard it: only allow it from a dedicated Artisan command that checks app()->environment(['local', 'testing']) or requires an explicit --force, and never expose it via a route.
Filters are a string DSL — build them, don't concatenate them. "'price' > context_item[\"price\"]" is ReQL-ish syntax with its own quoting rules. Interpolating user input into a filter is both a correctness bug and an injection surface. Wrap filter construction in small helper methods with typed parameters, and validate numeric values before they go in.
Eventual consistency is real. An item you just SetItemValues'd may not appear in recommendations for a short window, and a brand-new user with one interaction may get poor or empty results. Don't write tests asserting "item appears immediately." For cold start, either pass cascadeCreate/logic options, seed user properties in SetUserValues on registration (so the recommender has something), or fall back to non-personalised scenario results for users below an interaction threshold.
Batch responses need inspection, not blind trust. A Batch returns an array of per-request results in order. Partial failures are possible; iterate and log the entries that look like errors rather than assuming the whole batch landed. This is exactly why ingestion should be a queue job with retry semantics rather than a foreach in a controller.
Property schema is immutable-ish in practice. You can't change a property's declared type later without deleting and re-adding it (losing the values). Decide price is double and in_stock_from is timestamp on day one. Write the AddItemProperty calls into a versioned Artisan command so a new environment can be bootstrapped from zero.
Timestamps: pass DateTime objects and normalise to UTC. The client accepts DateTime instances (see the README). If your app runs Europe/Prague and Recombee stores UTC (or vice versa), time-decay boosting quietly misbehaves. Use now()->utc() and keep one conversion point.
Region selection is a latency decision, not a preference. us-west / eu-west / ap-se — pick the one nearest your app servers, not your office. A wrong region adds ~150–200ms round-trip to every read-path call and is the first thing to check when someone reports "recommendations feel slow."
Private token stays server-side. There are separate public/client tokens for browser and mobile SDKs. Never put the private token in a Blade template, a /config JSON endpoint, or a front-end bundle. If you must render recommendations client-side, generate them server-side through this SDK and pass the results.
returnProperties saves you a query. Recommendation calls can return item properties alongside IDs, which lets you build the response payload without a second whereIn against your own DB. Useful when your catalog is small and the recommendation rail doesn't need fresh pricing; skip it when prices are volatile and cached props would go stale.
Search is not a search-engine replacement. SearchItems gives you personalised full-text results from the Recombee catalog. It's excellent for "search results ordered by what this user will like," less so for facets, typos, and fuzzy matching. Common pattern: use your existing search (Scout/Meilisearch) to get candidate IDs, and Recombee for ranking/personalisation — or accept SearchItems for a fast v1 and revisit.
Octane / long-running workers: verify the singleton is stateless. A Client bound as a singleton is fine under Octane and Horizon as long as your wrapper holds no per-request state. Don't stash "current user" on the recommender service; pass it as an argument.
Pin the version and watch the changelog. The SDK is generated from Recombee's API spec, so minor versions can add request classes and options. ^6.2 is a sane constraint; read release notes when bumping in case an option you rely on (e.g. logic, diversity, rotationRate) changes shape.
Extension point to remember: when the SDK is missing an operation you need, don't fork it — check whether the underlying endpoint already has a Reqs\* class you haven't noticed (src/Requests/ is large), and if genuinely absent, extend Recombee\RecommApi\Requests\Request with the correct path and method rather than hand-rolling cURL. That keeps timeout/retry/exception behaviour consistent with everything else.
How can I help you explore Laravel packages today?