debello-io / constructor-laravel
Laravel integration for Constructor - AI-powered search, browse, autocomplete, recommendations, shopping agents and catalog management
Requires
- php: ^8.2
- guzzlehttp/guzzle: ^7.8
- illuminate/http: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- laravel/scout: ^11.0
- orchestra/testbench: ^10.0|^11.0
- phpstan/phpstan-phpunit: ^2.0
- phpunit/phpunit: ^11.0|^12.0
Suggests
- laravel/scout: Required to use the 'constructor' Scout engine (^11.0)
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-13 16:16:16 UTC
README
A Laravel package for Constructor product discovery: search, browse, autocomplete, recommendations, quizzes, the AI Shopping Agent and Product Insights Agent, catalog management, offline purchases and returns, and a Laravel Scout engine.
Constructor has official client libraries for JavaScript, Node, Java, Python and .NET, but not PHP. This package calls the REST API directly and handles the backend-integration requirements for you: shopper identity, forwarded headers, the security token and the client identifier. Every endpoint and parameter has been checked against the Constructor API reference.
- Requirements
- Installation
- Credentials
- Backend integration checklist
- Usage
- Error handling
- Testing your app
- Configuration reference
- Upgrading from 1.x
- Contributing
For a full walkthrough with complete controller and view examples, see the User Guide.
Requirements
- PHP 8.2+
- Laravel 12 or 13 (Laravel 13 needs PHP 8.3+)
- Optional: Laravel Scout 11 for the Scout engine
Installation
composer require debello-io/constructor-laravel
php artisan vendor:publish --tag=constructor-config
php artisan constructor:verify # after adding the credentials below
Credentials
Constructor uses three different credentials. They are not interchangeable.
# Public. Identifies your index. Dashboard: Integration > API Integration. CONSTRUCTOR_API_KEY=key_xxxxxxxx # Secret. Only for catalog management (uploads, items, tasks) and offline actions. CONSTRUCTOR_API_TOKEN=tok_xxxxxxxx # Secret. The backend "security token" Constructor gives you. It is sent as the # x-cnstrc-token header on every search/browse/autocomplete/recommendation/agent # request. Without it, requests from your servers are likely to be throttled. CONSTRUCTOR_BACKEND_TOKEN=xxxxxxxx # Required for the AI Shopping Agent. CONSTRUCTOR_AGENT_DOMAIN=your-agent-domain
| Credential | Used for | Sent as |
|---|---|---|
| API key | Every request | key query parameter |
| API token | Catalog management, merchandising configuration and offline behavioral actions only | HTTP Basic username |
| Backend token | Storefront and agent requests | x-cnstrc-token header |
| User profile token (optional) | Shopper preferences only | Authorization: Bearer header |
See Authentication and Required parameters.
Backend integration checklist
When your server calls Constructor on behalf of a shopper, Constructor still has to know who that shopper is. The package sends all of this automatically from the current request:
| Value | Source |
|---|---|
i (client ID) |
The beacon's ConstructorioID_client_id cookie. On a first visit, a UUID is generated and set as that cookie. |
s (session number) |
The beacon's ConstructorioID_session_id cookie, or 1 until it exists. |
ui (user ID) |
auth()->id(), only for signed-in shoppers. |
c (client identifier) |
cio-be-web-{your APP_NAME}, or CONSTRUCTOR_CLIENT_IDENTIFIER. |
User-Agent, X-Forwarded-For |
The shopper's browser and IP address. |
x-cnstrc-token |
CONSTRUCTOR_BACKEND_TOKEN. |
Your part:
-
Load the Constructor beacon on every page. It sets the cookies above and sends the behavioral events (clicks, add to cart, purchases) that Constructor learns from. This package does not send tracking events.
-
Expose the tracking data on the page. The beacon reads it from
data-cnstrc-*attributes. The package's Blade components write them for you, escaped, and add thecnstrc-datascript tag search and browse pages also need:<x-constructor::search-results :results="$results" :term="$query" class="grid"> @foreach ($results->products as $product) <x-constructor::item :product="$product" class="card"> <a href="{{ $product['url'] }}">{{ $product['name'] }}</a> @if ($product['is_sponsored']) <span>Sponsored</span> @endif <x-constructor::button action="add_to_cart">Add to cart</x-constructor::button> </x-constructor::item> @endforeach </x-constructor::search-results>
Component Use it for <x-constructor::search-results :results :term>Search results (adds the cnstrc-datatag)<x-constructor::browse-results :results filter-name filter-value>Category, brand and collection pages (adds the cnstrc-datatag)<x-constructor::item :product>Each result: ID, name, variation, price, Sponsored Listing campaign <x-constructor::recommendations :results :seed-items>A recommendation pod; <x-constructor::recommendation-item :product>for each item<x-constructor::quiz-results :results>Quiz results <x-constructor::product-detail :product>A product page (add modalfor a quick view)<x-constructor::button action>Add to cart and other conversions Every component takes a
tag(defaultdiv) and passes other attributes through; set adata-cnstrc-*attribute yourself to override a computed one, e.g. the variation ID after a swatch change. Not using Blade? See the raw markup in the User Guide, and Data-driven event tracking. -
Configure trusted proxies if you're behind a load balancer or CDN. Otherwise every request appears to come from the proxy's IP.
-
Respect consent. If a shopper hasn't consented to personalization, the client ID must be omitted:
// AppServiceProvider::boot() Constructor::resolveConsentUsing(fn (?Request $request) => $request?->cookie('consent') === 'all');
For opt-in regimes (GDPR), also set
CONSTRUCTOR_REQUIRE_CONSENT=trueso nobody is identified until your resolver says so. Without consent the package omitsi, as Constructor requires;ui,s, IP and user agent are still sent. If your consent banner sets that cookie from JavaScript, add it toEncryptCookies::except([...]). Otherwise Laravel can't decrypt it andcookie()returnsnull. -
Anonymize
ui(recommended by Constructor). Use a keyed hash: a plain hash of a sequential ID is easy to reverse.Constructor::resolveUserIdUsing(fn (?Request $request) => $request?->user() ? hash_hmac('sha256', (string) $request->user()->getAuthIdentifier(), config('app.key')) : null);
Register both resolvers once, in a service provider's
boot(). They are process-wide, so registering one per request (for example in middleware) on Octane would leak into other shoppers' requests. -
Don't share cached personalized results between shoppers. If you cache, include the client ID and user ID in the cache key. Full-page caches and CDNs must not cache responses that carry the generated
ConstructorioID_client_idcookie (Cache-Control: private), or every shopper would share one ID. -
Use the
webmiddleware group for routes that call Constructor. The first-visit client ID cookie is queued withCookie::queue(), which needsAddQueuedCookiesToResponse. On statelessapiroutes, let the beacon set the cookie, or passclient_id/session_idexplicitly. -
Hide the token in debugging tools. If you use Telescope, add
x-cnstrc-tokentoTelescope::hideRequestHeaders().
See Backend integration options.
Usage
use ConstructorIO\Laravel\Facades\Constructor;
You can also inject ConstructorIO\Laravel\Contracts\SearchProvider (or ConstructorIO\Laravel\Constructor) instead of using the facade.
Search
$results = Constructor::search('pellet grill', [ 'brand' => ['Traeger', 'Weber'], // values for one facet are ORed 'fuel_type' => 'Pellet', // different facets are ANDed ], [ 'page' => 1, 'per_page' => 24, // max 200 'sort_by' => 'price', 'sort_order' => 'ascending', 'range_filters' => ['price' => ['min' => 300, 'max' => 1500]], ]); if ($results->isRedirect()) { // a redirect rule matched in the dashboard return redirect($results->redirectUrl()); } foreach ($results->products as $product) { $product['name']; $product['price']; $product['image_url']; $product['url']; $product['_raw']; // every catalog field $product['is_sponsored']; // Sponsored Listing: show a "Sponsored" badge } $results->total; // Constructor serves at most 10,000 of them: totalPages() stops there $results->facets; // ['brand' => ['name' => 'Brand', 'type' => 'checkbox_list', 'options' => [...], 'values' => [...]]] $results->sortOptions; // sort options configured in the dashboard $results->resultId; // pass to the page for tracking
Voice and conversational queries go to naturalLanguageSearch(), which takes the same filters and options and returns the same SearchResults. Constructor turns phrases like "under 500 dollars" into filters:
$results = Constructor::naturalLanguageSearch('show me a gas grill for under 500 dollars');
Search by photo with searchByImage() (Image Search, enabled per account by Constructor). Pass the uploaded file; JPEG, PNG, WebP or GIF up to 6 MB, checked from the file's contents before anything is sent. Image Search has no filters, so narrow the matches with filterImageResults():
$request->validate(['photo' => 'required|image|max:6144']); $matches = Constructor::searchByImage($request->file('photo')); // SearchResults $weber = Constructor::filterImageResults($matches, ['brand' => 'Weber']);
A string is treated as image bytes, never as a file path. For a file on your own server, pass new SplFileInfo($path).
Every option (offset, filter_match_types, pre_filter_expression, hidden_fields, fmt_options, variations_map, user_segments, lang, parameters, ...) is documented on ParameterBuilder.
Browse (category, brand and collection pages)
$grills = Constructor::browse('group_id', 'gas-grills', ['brand' => 'Weber'], ['per_page' => 24]); $weber = Constructor::browse('brand', 'Weber'); $categories = Constructor::getBrowseGroups(['limit' => 10, 'children_limit' => 5]); $collections = Constructor::getCollections(['per_page' => 10]); $collection = Constructor::getCollection('summer-cookout'); // name, data, or null $products = Constructor::browseCollection('summer-cookout'); $recentlyViewed = Constructor::browseItems(['TRG-780', 'WEB-SPIRIT-E325']);
Facets with their options, for a filter sidebar or "Shop by" menu without running a search (at most 10 facets per request; page through the rest):
$page = Constructor::getFacetsWithOptions(['page' => 1]); $page['facets']['brand']['options']; // same shape as $results->facets $page['has_more'];
Product details (enriched attributes and badges)
For a product page, Constructor can return what it has added to the product: Attribute Enrichment results and badges such as "Bestseller" or "Bought 63 times last week".
$details = Constructor::productDetails()->getItem('TRG-780'); // null if Constructor is unavailable $details['badges']; // [['type' => 'is_global_bestseller', 'display_name' => 'Bestseller', 'metadata' => []]] $details['attribute_values']; // ['fuel_type' => ['pellet'], 'color' => ['black']] $several = Constructor::productDetails()->getItems(['TRG-780', 'WEB-SPIRIT-E325']); // up to 20, in order $variations = Constructor::productDetails()->getVariations(['TRG-780-BLK']); // up to 100
This isn't the catalog record. Name, price and your own fields come from your database or from browseItems(), which returns products in the same shape as search results. Use product details only for Constructor's additions. Listing pages get badges in each result's labels instead. Requests go to CONSTRUCTOR_PRODUCT_DETAILS_BASE_URL (default https://product-details.cnstrc.com) with the shopper's identity, and fail gracefully like other storefront reads. See Product details.
Autocomplete
$results = Constructor::autocomplete('web', [ 'sections' => [ 'suggestions' => ['limit' => 5], 'products' => ['limit' => 6], ], ]); $results->suggestions; // [['term' => 'weber grill', 'matched_terms' => [...]], ...] $results->products; $results->sections; // every section returned, including custom ones // Empty, focused search box: $zeroState = Constructor::getZeroStateData(['recommendation_pod_id' => 'zero-state-bestsellers']);
Recommendations
$bestsellers = Constructor::getRecommendations('home-bestsellers', ['limit' => 8]); // Item-based pods (alternatives, complementary items, bundles). Pass one ID or several (a cart). $accessories = Constructor::getItemRecommendations('pdp-complementary', 'TRG-780', ['limit' => 4]); $accessories->title; // the pod's display name $accessories->products;
Pod-specific parameters are supported: term (query recommendations), filters (filtered items), variation_id. See Recommendations.
Fetch every pod on a dashboard-configured page in one request. Constructor removes duplicates across the pods. This endpoint is in beta, so ask Constructor to enable it.
$page = Constructor::getRecommendationPage('pdp', [ 'item_id' => 'TRG-780', // shared by every pod 'pod_overrides' => ['pdp-similar' => ['limit' => 12]], // per-pod settings ]); foreach ($page->podsWithResults() as $pod) { // in the page's configured order $pod->title; $pod->products; $pod->resultId; // track each pod with its own resultId } $page->pod('pdp-complementary');
See Retrieve by page and the User Guide for the tracking markup.
AI Shopping Agent
$agent = Constructor::agent(); $reply = $agent->askShoppingAgent('A smoker for a small patio, under $500'); $reply['message']; // the agent's text $reply['products']; // recommended products $reply['refinements']; // follow-up questions with options $reply['thread_id']; // pass back to continue the conversation $followUp = $agent->askShoppingAgent('Something electric instead', $reply['thread_id']);
Stream the answer to the browser as it is generated. Forward only the events your UI needs: complete carries the raw response, including any hidden fields you requested.
use ConstructorIO\Laravel\Exceptions\ConstructorException; return response()->stream(function () use ($agent, $query) { $send = function (string $event, mixed $data) { echo "event: {$event}\ndata: ".json_encode($data)."\n\n"; ob_flush(); flush(); }; try { $agent->askShoppingAgentStreaming($query, function (string $event, mixed $data) use ($send) { if (in_array($event, ['start', 'message', 'products', 'follow_up', 'end'], true)) { $send($event, $data); } }); } catch (ConstructorException $e) { report($e); $send('error', ['message' => 'The assistant is unavailable. Please try again.']); } }, 200, ['Content-Type' => 'text/event-stream', 'Cache-Control' => 'no-cache', 'X-Accel-Buffering' => 'no']);
Agent text can be influenced by what shoppers type, so render it as text (textContent, not innerHTML) and only link to URLs on your own domain.
Product Insights Agent
$questions = $agent->getProductQuestions('TRG-780'); // ['questions' => ['Can it cold smoke?', ...]] $answer = $agent->askProductQuestion('Can it cold smoke cheese?', 'TRG-780'); $answer['answer']; $answer['follow_up_questions']; $answer['item_results']; // related products
Agent Insights
AI answers placed inside the normal shopping journey. Every mode returns an AgentInsight with the same shape:
$insights = Constructor::insights(); // An AI overview above the search results (the index's searchbar domain) $overview = $insights->searchOverview('smoker for a small patio', ['page_context' => ['page_type' => 'plp']]); if ($overview->hasContent()) { $overview->message; $overview->products(); } $overview->isDeclined(); // Constructor judged traditional search better (HTTP 422): show your results // A chat turn (CONSTRUCTOR_AGENT_DOMAIN, or pass 'domain') $reply = $insights->chat('Electric instead', ['thread_id' => $previous->threadId]); // A product answer, a curated recommendation set, and listing refinement chips $answer = $insights->answerQuestion('Does it fit a 20 lb tank?', 'WEB-SPIRIT-E325'); $set = $insights->recommendations('WEB-SPIRIT-E325', 'complementary_pod'); $refined = $insights->refineListing(['search_term' => 'grill'], 'under $500'); $refined->followUpChoices(); // [['label' => 'Pellet', 'filter' => [...]], ...] $refined->refinedListing(); // send back as the listing next time
searchOverview(), recommendations() and refineListing() decorate a page, so they log failures and return an empty result; chat() and answerQuestion() throw. Each method has a *Streaming() twin that passes Constructor's events to a callback as they arrive. See Agent insights.
Quizzes
Guided product finders built in the Constructor dashboard. Pass every answer so far, plus the session and version IDs from the first response:
$quizzes = Constructor::quizzes(); $step = $quizzes->getNextQuestion('grill-finder'); // first question $step->question['title']; $step->type(); $step->options(); $step = $quizzes->getNextQuestion('grill-finder', [[1], [3, 4]], [ // one entry per answered question 'quiz_session_id' => $step->quizSessionId, 'quiz_version_id' => $step->quizVersionId, ]); if ($step->isComplete()) { $results = $quizzes->getResults('grill-finder', [[1], [3, 4]], [], ['quiz_session_id' => $step->quizSessionId]); $results->products; $results->resultId; }
Select answers are lists of option IDs ([] for skipped), open-text answers are true/false (the text is never sent), and cover pages take QuizService::COVER_PAGE_SEEN. Mark up the results with data-cnstrc-quizzes, data-cnstrc-quiz-id, data-cnstrc-quiz-version-id, data-cnstrc-quiz-session-id and data-cnstrc-result-id. See Quizzes and the User Guide.
Catalog management
Requires CONSTRUCTOR_API_TOKEN.
$catalog = Constructor::catalog(); // Full replace. Format (csv or jsonl) comes from the file extension. $upload = $catalog->uploadCatalog(storage_path('catalog/items.jsonl'), 'create_or_replace', [ 'variations_file' => storage_path('catalog/variations.jsonl'), 'item_groups_file' => storage_path('catalog/item_groups.jsonl'), 'notification_email' => 'catalog-alerts@example.com', ]); // Partial update that only changes the fields you send. $catalog->uploadCatalog(storage_path('catalog/price-updates.jsonl'), 'patch', ['patch_delta' => true]); $status = $catalog->waitForTaskCompletion($upload['task_id']); // throws TaskTimeoutException $status['successful']; $status['warnings']; $status['indexed']; // DONE can come before the change is searchable // Individual items (batched automatically, max 10,000 per request) $catalog->upsertItems([['id' => 'TRG-780', 'name' => 'Pro 780', 'data' => ['price' => 999]]]); $catalog->patchItems([['id' => 'TRG-780', 'data' => ['price' => 899]]]); $catalog->deleteItems(['OLD-SKU-1']); $catalog->getItem('TRG-780'); // Variations (each names its parent item) and item groups (the category tree) $catalog->upsertVariations([['id' => 'TRG-780-LP', 'item_id' => 'TRG-780', 'fuel_type' => 'Propane']]); $catalog->upsertItemGroups([['id' => 'pellet-grills', 'name' => 'Pellet Grills', 'parent_ids' => ['grills']]]); $catalog->listVariations(options: ['item_ids' => ['TRG-780']]); $catalog->getItemGroup('pellet-grills');
Variations have the same methods as items (upsertVariations, patchVariations, deleteVariations, getVariation, listVariations), and so do item groups (upsertItemGroups, patchItemGroups, deleteItemGroups, getItemGroup, listItemGroups). Item groups belong to the whole index, so they take no section.
Large uploads can take minutes, so run them from a queued job. See Catalog management.
Frequent small updates: the Catalog Batching Service
For prices and stock that change many times a minute, use Constructor's Catalog Batching Service. It doesn't start a task per request, and creates records that don't exist yet:
$batching = Constructor::catalogBatching(); $batching->patchItems([['id' => 'TRG-780', 'name' => 'Pro 780', 'price' => 899]]); // id and name required $batching->patchVariations([['id' => 'TRG-780-LP', 'item_id' => 'TRG-780', 'in_stock' => false]]); $batching->deleteItems(['OLD-SKU-1']); // returns ['accepted' => 1, 'responses' => [...]]
Lists are sent 100 records per request (the service's limit). Requests go to CONSTRUCTOR_CATALOG_BATCHING_BASE_URL (default https://batching.catalog.cnstrc.com) with your API token, and are never retried automatically: on a RateLimitException, back off and resend.
From the command line
php artisan constructor:upload storage/catalog/items.jsonl --variations=storage/catalog/variations.jsonl --wait php artisan constructor:upload storage/catalog/prices.csv --operation=patch --patch-delta php artisan constructor:task 12345 --wait
constructor:upload takes the same settings as uploadCatalog() (--section, --mode, --on-missing, --email, --allow-large-removal for Constructor's force flag). A full replace asks for confirmation in production unless you pass --force. Both commands exit with 1 if the task fails, so they work in deploy scripts and schedulers.
Offline purchases and returns
In-store and phone orders never reach the beacon. Send them so Constructor learns from them too. Requires CONSTRUCTOR_API_TOKEN; run it from a queued job or scheduled command.
Constructor::behavior()->recordOfflinePurchases([[ 'order_id' => 'POS-10293', // events with the same order ID are merged 'occurred_at' => $sale->completed_at, // required 'user_id' => $sale->customer_id, // the same `ui` your website sends 'store_id' => 'austin-tx', 'items' => [['item_id' => 'TRG-780', 'price' => 999.99, 'count' => 1]], ]]); Constructor::behavior()->recordOfflineReturns([[ 'order_id' => 'POS-10293', 'occurred_at' => now(), 'items_returned' => [['item_id' => 'TRG-780']], 'return_type' => 'full', ]]);
Actions are validated before anything is sent and posted in batches of 100 (Constructor's limit); writes are never retried. See Offline behavioral actions.
Shopper preferences
Semi-permanent facts a shopper tells you (preferred fuel type, loyalty tier) improve personalization. Send them to Constructor's User profile API:
// The signed-in shopper, e.g. after a preferences form. Uses the same `ui` as storefront // requests; returns false (and sends nothing) for guests or without consent. Constructor::preferences()->setForCurrentUser('fuel_type', ['pellet', 'charcoal']); // Back-office batches (100 per request) and full backfills (JSONL or .jsonl.gz): Constructor::preferences()->setPreferences([ ['user_id' => $hashedUserId, 'preference_key' => 'loyalty_tier', 'preference_values' => ['gold']], ]); Constructor::preferences()->uploadPreferencesFile(storage_path('exports/preferences.jsonl.gz'));
- Token: this API only accepts Bearer authentication, with a dashboard API token that has the
user_profile.user_preferences(w)scope. Set it asCONSTRUCTOR_USER_PROFILE_TOKEN. The API token is never used in its place. - Matching IDs:
user_idmust be the exactuiyour storefront sends. If you hash IDs withresolveUserIdUsing(), hash batch IDs the same way. - Personal data: only send what shoppers agreed you may use for personalization. Values are never logged. The API has no read or delete endpoint, so ask Constructor how to handle a deletion request.
- Temporary facts (campaign, location) belong in the
user_segmentsoption on search calls instead.
Merchandising configuration
Manage redirect rules, synonyms, sort options, facets and searchandising rules from code, for example to keep them in version control or sync them from your PIM. Requires CONSTRUCTOR_API_TOKEN.
$config = Constructor::configuration(); $config->redirectRules()->create(['url' => '/gift-cards', 'matches' => [['pattern' => 'gift card', 'match_type' => 'PHRASE']]]); $config->synonymGroups()->create(['bbq', 'barbecue']); $config->oneWaySynonyms()->create('smoker', ['pellet grill', 'offset smoker']); $config->sortOptions()->create(['sort_by' => 'price', 'sort_order' => 'ascending', 'path_in_metadata' => 'data.price', 'display_name' => 'Price: Low to High']); $config->facets()->update('brand', ['display_name' => 'Brand', 'sort_order' => 'value']); $config->facetOptions()->updateMany('brand', [['value' => 'weber', 'display_name' => 'Weber']]); $config->refinedQueries()->create([ 'query' => 'smoker', 'slot_rules' => [['rule' => ['item_id' => 'TRG-780', 'position' => 1]]], // pin to the top ]);
Every resource uses the same method names where the API allows: list(), get() (null if not found), create(), replace(), update() and delete(). Bulk writes are split to Constructor's per-request limits. Calls throw on failure; reads are retried, writes never are. See Configuration and Searchandising.
Laravel Scout
SCOUT_DRIVER=constructor
use ConstructorIO\Laravel\Scout\ConstructorSearchable; use Laravel\Scout\Searchable; class Product extends Model implements ConstructorSearchable { use Searchable; public function getConstructorSection(): string { return 'Products'; } public function toConstructorItem(): array { return [ 'name' => $this->name, 'data' => [ 'url' => route('products.show', $this), 'image_url' => $this->image_url, 'price' => $this->price, 'group_ids' => $this->categories->pluck('slug')->all(), ], ]; } } $products = Product::search('smoker')->where('brand', 'Traeger')->paginate(24);
Requires Laravel Scout 11. The item ID is the model's Scout key. Without ConstructorSearchable, toSearchableArray() is used: name stays at the top level and every other field moves into data. Scout searches go through the same storefront client, so they carry the shopper's identity too.
CONSTRUCTOR_SCOUT_SYNC_MODE=patchsends model saves as partial updates, keeping fields other feeds maintain (inventory, merchandising data). The default,replace, replaces the whole item.- With
scout.soft_delete, items carrydata.__soft_deleted("0"/"1"); configure__soft_deletedas a hidden facet in Constructor so Scout's filter works. - Most retailers sync the catalog with file uploads and use Scout only for incremental updates.
Caching
Category menus, facet lists and collections are the same for every shopper, so you can cache them. It's off by default:
CONSTRUCTOR_CACHE_ENABLED=true CONSTRUCTOR_CACHE_TTL=600 # seconds CONSTRUCTOR_CACHE_TTL_CATEGORIES=3600
Constructor::getBrowseGroups(); // cached Constructor::getBrowseGroups(['cache' => false]); // always asks Constructor Constructor::flushCache(); // after changing categories or facets in the dashboard
Cached: getBrowseGroups(), getAvailableFacets(), getFacetsWithOptions(), getFacetValuesWithImages(), getCollections() and getCollection(). Search, browse, autocomplete, recommendations, quizzes and the agents are personalized and never cached. Failures are never cached, and user segments and other options get their own entries. See the User Guide.
Error handling
| Call | On failure |
|---|---|
| Search, browse, autocomplete, recommendations, facets, groups, collections, quizzes, Agent Insights overviews, recommendation sets and listing refinements | Logs a warning and returns an empty result (for getNextQuestion(), a step where isAvailable() is false), so a page still renders. Set CONSTRUCTOR_THROW_EXCEPTIONS=true to throw instead. |
AI agents (incl. Agent Insights chat() and answerQuestion()), catalog management, offline behavioral actions, Scout indexing |
Throws. |
| Missing API key, token or agent domain | Always throws ConfigurationException. |
Every exception extends ConstructorIO\Laravel\Exceptions\ConstructorException:
AuthenticationException: 401 or 403RateLimitException: 429. On storefront calls this usually means a missingCONSTRUCTOR_BACKEND_TOKEN.ApiException: any other error status; seegetStatusCode()andgetResponseBody(). Messages never include the response body, because it can echo search terms or filter values.ConnectionException: Constructor could not be reachedTaskTimeoutException,InvalidCatalogDataException: catalog managementInvalidArgumentException: an argument Constructor would reject (e.g. more than 100 IDs forbrowseItems()), thrown before any request
Only reads are retried, and only after connection errors, 429 and 5xx responses, with exponential backoff that honours Retry-After (at most 5 seconds). Other 4xx responses, catalog writes and uploads are never retried. Logs and exception messages never include tokens, response bodies or shopper identifiers, and URL-holding parameters are hidden from stack traces.
Testing your app
Fake Constructor. Nothing is sent and no credentials are needed; calls are recorded, and anything you don't stub returns an empty result:
use ConstructorIO\Laravel\DataTransferObjects\RecommendationResults; use ConstructorIO\Laravel\DataTransferObjects\SearchResults; use ConstructorIO\Laravel\Facades\Constructor; Constructor::fake([ 'search' => new SearchResults(products: [['id' => 'TRG-780', 'name' => 'Pro 780']], total: 1, page: 1, perPage: 24), 'getRecommendations' => fn (string $podId, array $options) => RecommendationResults::empty($podId), ]); $this->get('/search?q=grill')->assertSee('Pro 780'); Constructor::assertSearched('grill'); Constructor::assertSearched(fn (string $query, array $filters, array $options) => $filters === ['brand' => 'Weber']); Constructor::assertBrowsed('group_id', 'gas-grills'); Constructor::assertRecommended('pdp-similar'); Constructor::assertCalled('getCollection', fn (string $id) => $id === 'summer-cookout'); Constructor::assertNothingCalled();
The services behind Constructor::agent(), catalog(), quizzes() and the other accessors also stay offline. To make one return specific data, mock it: $this->mock(AgentService::class, ...). Mocking the facade with Constructor::shouldReceive(...) works as well.
Configuration reference
Every setting is documented in config/constructor.php and .env.example. The main ones:
| Env | Default | Purpose |
|---|---|---|
CONSTRUCTOR_API_KEY |
Public index key | |
CONSTRUCTOR_API_TOKEN |
Catalog management | |
CONSTRUCTOR_BACKEND_TOKEN |
x-cnstrc-token header |
|
CONSTRUCTOR_CLIENT_IDENTIFIER |
cio-be-web-{APP_NAME} |
c parameter |
CONSTRUCTOR_SECTION |
Products |
Default index section |
CONSTRUCTOR_GENERATE_CLIENT_ID |
true |
Create the beacon's client ID cookie on a first visit |
CONSTRUCTOR_SEND_USER_ID |
true |
Send auth()->id() as ui |
CONSTRUCTOR_REQUIRE_CONSENT |
false |
Withhold i until resolveConsentUsing() returns true |
CONSTRUCTOR_AGENT_DOMAIN |
AI Shopping Agent domain | |
CONSTRUCTOR_TIMEOUT / CONSTRUCTOR_STREAM_TIMEOUT |
10 / 120 |
Seconds |
CONSTRUCTOR_RETRY_ATTEMPTS / CONSTRUCTOR_RETRY_DELAY |
2 / 100 |
Total attempts (max 5) / first delay in ms |
CONSTRUCTOR_THROW_EXCEPTIONS |
false |
Throw on storefront failures |
CONSTRUCTOR_LOG_CHANNEL |
default | Where failures are logged |
CONSTRUCTOR_NOTIFICATION_EMAIL |
Catalog failure notifications | |
CONSTRUCTOR_CATALOG_BATCH_SIZE |
1000 |
Records per /v2/items, /v2/variations or /v2/item_groups request (max 10,000) |
CONSTRUCTOR_CATALOG_BATCHING_BASE_URL |
https://batching.catalog.cnstrc.com |
Catalog Batching Service host |
CONSTRUCTOR_SCOUT_SYNC_MODE |
replace |
replace or patch model saves |
CONSTRUCTOR_QUIZZES_BASE_URL |
https://quizzes.cnstrc.com |
Quizzes host (https only) |
CONSTRUCTOR_CACHE_ENABLED |
false |
Cache category, facet and collection responses |
CONSTRUCTOR_CACHE_STORE / CONSTRUCTOR_CACHE_PREFIX |
default store / constructor |
Where cached responses go |
CONSTRUCTOR_CACHE_TTL |
600 |
Seconds to keep a cached response; CONSTRUCTOR_CACHE_TTL_CATEGORIES, _FACETS and _COLLECTIONS override it per type |
CONSTRUCTOR_IMAGE_SEARCH_BASE_URL |
https://image-search.cnstrc.com |
Image Search host (https only) |
CONSTRUCTOR_PRODUCT_DETAILS_BASE_URL |
https://product-details.cnstrc.com |
Product details host |
CONSTRUCTOR_USER_PROFILE_TOKEN |
Bearer token for shopper preferences | |
CONSTRUCTOR_USER_PROFILE_BASE_URL |
https://user-profile.cnstrc.com |
User profile host |
Upgrading from 1.x
2.0 fixes the integration to match Constructor's documentation, which changes some behaviour. The 1.x class names still work as deprecated aliases. See UPGRADE.md for the full list.
Contributing
See CONTRIBUTING.md and AGENTS.md. The repository is set up for Claude Code: the Constructor docs MCP server, skills and a review subagent are configured in .mcp.json and .claude/. Run composer check before opening a pull request.
Security issues: see SECURITY.md.
License
MIT. See LICENSE.