Search by

debello-io / constructor-laravel

timjohnbancroft

Laravel integration for Constructor - AI-powered search, browse, autocomplete, recommendations, shopping agents and catalog management

Package info

github.com/Debello-io/constructor-laravel

pkg:composer/debello-io/constructor-laravel

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 1

v2.0.0 2026-11-13 15:07 UTC

This package is auto-updated.

Last update: 2026-11-13 16:16:16 UTC


README

CI License

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.

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:

  1. 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.

  2. 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 the cnstrc-data script 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-data tag)
    <x-constructor::browse-results :results filter-name filter-value> Category, brand and collection pages (adds the cnstrc-data tag)
    <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 modal for a quick view)
    <x-constructor::button action> Add to cart and other conversions

    Every component takes a tag (default div) and passes other attributes through; set a data-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.

  3. Configure trusted proxies if you're behind a load balancer or CDN. Otherwise every request appears to come from the proxy's IP.

  4. 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=true so nobody is identified until your resolver says so. Without consent the package omits i, as Constructor requires; ui, s, IP and user agent are still sent. If your consent banner sets that cookie from JavaScript, add it to EncryptCookies::except([...]). Otherwise Laravel can't decrypt it and cookie() returns null.

  5. 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.

  6. 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_id cookie (Cache-Control: private), or every shopper would share one ID.

  7. Use the web middleware group for routes that call Constructor. The first-visit client ID cookie is queued with Cookie::queue(), which needs AddQueuedCookiesToResponse. On stateless api routes, let the beacon set the cookie, or pass client_id / session_id explicitly.

  8. Hide the token in debugging tools. If you use Telescope, add x-cnstrc-token to Telescope::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 as CONSTRUCTOR_USER_PROFILE_TOKEN. The API token is never used in its place.
  • Matching IDs: user_id must be the exact ui your storefront sends. If you hash IDs with resolveUserIdUsing(), 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_segments option 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=patch sends 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 carry data.__soft_deleted ("0"/"1"); configure __soft_deleted as 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 403
  • RateLimitException: 429. On storefront calls this usually means a missing CONSTRUCTOR_BACKEND_TOKEN.
  • ApiException: any other error status; see getStatusCode() and getResponseBody(). Messages never include the response body, because it can echo search terms or filter values.
  • ConnectionException: Constructor could not be reached
  • TaskTimeoutException, InvalidCatalogDataException: catalog management
  • InvalidArgumentException: an argument Constructor would reject (e.g. more than 100 IDs for browseItems()), 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.