justinholtweb/craft-bee

Recombee recommendations for Craft CMS and Craft Commerce — catalog sync, interaction tracking, personalised recommendations and search, without writing a line of API code.

Maintainers

Package info

github.com/justinholtweb/craft-bee

Type:craft-plugin

pkg:composer/justinholtweb/craft-bee

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

dev-main 2026-08-28 23:16 UTC

This package is auto-updated.

Last update: 2026-08-29 12:02:40 UTC


README

Recombee recommendations for Craft CMS and Craft Commerce.

Bee connects a Craft site to Recombee — catalog sync, interaction tracking, personalised recommendations and search — without writing a line of API code. Templates get Craft elements back, in Recombee's order, ready to loop over.

{% for product in craft.bee.recommend({ count: 6, scenario: 'homepage' }) %}
  <a href="{{ product.url }}">{{ product.title }} — {{ product.price|commerceCurrency }}</a>
{% endfor %}
  • Craft CMS 5.3+, PHP 8.2+
  • Craft Commerce 5 supported, and entirely optional
  • No runtime dependencies. The Recombee client is ~200 lines using Craft's own Guzzle.
  • Lite is free. Pro is $149.

What it does

Catalog sync. Say which sections, product types, volumes or category groups become Recombee items, and what Recombee is told about them. Elements are pushed when they are saved, removed when they are unpublished or deleted, and re-sent in bulk from a console command. A content fingerprint means an unchanged element costs nothing.

Interaction tracking. A dependency-free front-end runtime records detail views with real dwell time. Commerce's order funnel becomes purchases and cart additions. Everything else — bookmarks, ratings, view portions — is a one-line Twig or JavaScript call.

Recommendations and search. Personalised recommendations, related items, item segments and Recombee's own personalised search, all returning Craft elements.

Attribution. Bee records which recommendation put an item in front of a visitor and attaches that recommId to the interaction that follows. That is the only way Recombee can score — or learn from — its own suggestions, and it is what turns the Recombee console's revenue reporting on.

Historic backfill. A brand-new Recombee database knows nothing. Your Craft install is sitting on years of orders. php craft bee/interactions/backfill replays them, with their original timestamps, and is safe to run twice.

Setup

composer require justinholtweb/craft-bee
php craft plugin/install bee

Then, in .env:

BEE_DATABASE_ID="your-database-id"
BEE_PRIVATE_TOKEN="your-private-token"

and in Settings → Bee, point the two fields at those variables, pick the region your Recombee database was created in, and press Test connection.

A database reached in the wrong region answers 401, not 404. If the credentials look right and you are still getting 401, check the region first.

Then:

  1. Bee → Catalog → New source. Pick an element type and the sections or product types it covers. Add property mappings for anything you want to filter, boost or display on.
  2. Sync everything. Or php craft bee/sync/catalog.
  3. Bee → Diagnostics. Ten checks, each with a fix, in the order things have to be true.

Catalog sources

A source is "these elements become Recombee items, with these properties". Sources live in project config, so they travel with a deploy rather than needing to be re-clicked in every environment.

Properties Bee always sends

title, url, imageUrl, itemType, siteId, sourceHandle, slug, enabled, postDate, expiryDate, updatedAt.

These are not optional. Every recommendation request is filtered on enabled, postDate and expiryDate, because the catalog is push-based and can lag — without that filter the first symptom of a sync problem is recommending something that was unpublished an hour ago.

Mappings

Each mapping is a Recombee property name, a type, and where to read it from:

Read from Value
Custom field a field handle
Element attribute title, slug, postDate, url, …
Built-in mapper one of the keys below
Twig an object template, e.g. {{ object.author.fullName }}

Built-in mappers cover the common cases: image, images, author, categories, categoryIds, tags, wordCount, readingMinutes, ancestors, kind, plus the Commerce family — commerce:price, commerce:promotionalPrice, commerce:onSale, commerce:sku, commerce:stock, commerce:inStock, commerce:productType, commerce:variantCount, commerce:variantSkus, commerce:minPrice, commerce:maxPrice, commerce:weight, commerce:productId.

Values are coerced to the declared Recombee type, and dropped if they cannot be. A property declared double that receives "12.00" would otherwise get the whole item rejected.

Recombee has one property namespace per database. Two sources that both send price have to agree on its type; Bee reports the disagreement rather than silently breaking one of them.

Item IDs

e42 for an entry, p42 for a product, v42 for a variant, c42, a42, and so on. As soon as more than one site is synced the site ID joins them: e42-s2. Changing which sites are synced therefore changes every item ID — re-sync with --force afterwards, and bee/sync/purge the strays. Diagnostics will tell you if this has happened.

Recommendations in templates

{# Personalised for the visitor #}
{% set recs = craft.bee.recommend({ count: 6, scenario: 'homepage' }) %}

{# Related to this item #}
{% set related = craft.bee.related(product, { count: 4, scenario: 'product-detail' }) %}

{# Recombee's own search (Pro) #}
{% set results = craft.bee.search(craft.app.request.getParam('q'), { count: 24 }) %}

{# Best of a segment (Pro) #}
{% set best = craft.bee.segment('brand-acme', { count: 8 }) %}

Each returns a RecommendationSet. Loop it and you get Craft elements, in Recombee's order, with anything Craft can no longer resolve dropped:

{% for product in related %}
  <a href="{{ product.url }}" {{ craft.bee.attribution(related, product) }}>
    {{ product.title }}
  </a>
{% else %}
  {# Nothing came back. Recommendations never throw — always have a fallback. #}
  {% for product in craft.commerce.products.limit(4).all() %}…{% endfor %}
{% endfor %}

craft.bee.attribution() renders the two data attributes the runtime reads on click, so the detail view on the next page carries the recommId. Without it, Recombee cannot tell you whether its recommendations are working.

Options

Option
count how many. Defaults to the setting.
scenario the Recombee scenario, e.g. homepage, cart, emailing.
filter extra ReQL, combined with (not replacing) the live-content filter.
live false to drop the live-content filter.
siteId which site to resolve elements in.
userId override the visitor. Never accept this from a request.
booster, logic, diversity, expertSettings, reqlExpressions, returnAbGroup passed straight through. Pro.

On the set

recs.recommId the ID to attribute interactions to
recs.ids() the raw Recombee item IDs
recs.elements() the resolved Craft elements
recs.values() properties Recombee returned, if you asked for them
recs.isEmpty(), recs.failed nothing came back / the request failed
recs.hasMore(), recs.next() the next page of the same recommendation. Pro.
recs.abGroup the A/B group, when returnAbGroup was set

Tracking

The front-end runtime handles the usual case on its own. Tell it what the page is about:

<body {{ craft.bee.pageItem(entry) }}>

and it will record a detail view once the visitor has actually stayed (three seconds by default — firing on load counts every bounce and every prefetch as interest), send the final dwell time on unload, and carry the recommId from whatever recommendation they clicked to get here.

Everything else, from JavaScript:

Bee.cartAdd('v1234', 2, 19.99);
Bee.bookmark('e42');
Bee.rate('e42', 0.5);      // Recombee's scale runs -1 … 1
Bee.portion('e42', 0.75);
Bee.stop();                // consent declined after load

or from Twig, server-side:

{% do craft.bee.trackView(entry) %}
{% do craft.bee.trackRating(entry, craft.bee.stars(4)) %}   {# 4 out of 5 → 0.5 #}

The visitor is always resolved server-side, from the session or a signed first-party cookie. The browser cannot name a user, the tracking endpoint refuses items that are not in the catalog, and it is rate limited. Nothing personal is stored against a guest ID.

Identity

Signed in u{user uid}
Guest g{random token}, in a first-party cookie
Guest signs in, or checks out the two are merged in Recombee (Pro)

That merge is the point. Most of a visitor's history happens before they have an account, and throwing it away at the moment they sign up is throwing away the part that led to the sale.

Consent

Set Consent to "Only when a consent cookie says so" and name the cookie your CMP sets. Unknown consent is treated as not granted — a visitor who has not answered the banner has not said yes, and an interaction sent now cannot be recalled later.

Caching

Bee writes nothing visitor-specific into a page. The runtime's configuration is page-specific, the consent answer is read from a cookie in the browser, and the guest cookie is minted by the tracking endpoint rather than the page render. Pages carrying the runtime stay cacheable.

craft.bee.recommend() is the exception, and unavoidably so: a personalised block is personal.

Commerce

Install Commerce and Bee picks it up. Add a Product or Variant source, and:

  • Purchases are recorded on order completion, one per line item, with the unit price and the quantity — and the order's own timestamp, not now.
  • Cart additions are recorded when a line item is genuinely new (Pro). A quantity edit and a shipping recalculation both re-save every line on the order; neither is an "add to cart".
  • Guest checkout merges the cookie profile into the account Commerce creates (Pro).
  • Historic orders replay with php craft bee/interactions/backfill.

Variants are preferred over products when both are synced — the variant is the thing with a price, a SKU and a stock level — and Bee falls back to the product when only products are in the catalog.

Everything Commerce lives in one service. A content-only install never loads a Commerce class.

Console

php craft bee/sync/catalog                    # sync every source
php craft bee/sync/catalog --force            # re-send even unchanged items
php craft bee/sync/catalog --source=Products  # one source
php craft bee/sync/catalog --dry-run          # build and log, send nothing
php craft bee/sync/properties                 # create declared item properties
php craft bee/sync/element 1234               # one element
php craft bee/sync/purge                      # delete excluded and failed items

php craft bee/interactions/backfill --since=2024-01-01 --limit=5000

php craft bee/log/tail --failures
php craft bee/log/prune --days=7
php craft bee/log/clear

php craft bee/diagnostics/check               # exits non-zero on an error
php craft bee/diagnostics/check --offline     # no requests to Recombee

bee/diagnostics/check exits non-zero when something is actually broken, so it can gate a deploy.

Diagnostics

Bee → Diagnostics runs the same checks as the console, in the order things have to be true: plugin enabled → credentials → connection → sources → catalog → properties → recommendations → tracking → consent → multi-site → Commerce → recent failures. Every non-OK result carries a fix.

Bee → Log is the connection log: every request, its status, its duration, and both payloads. Credentials are never in it — the signature and the token live in the query string, and only the path is recorded.

Dry run builds and logs every request and sends nothing, which is how to see exactly what would be pushed before pointing Bee at a real database.

Lite and Pro

Lite Pro
Catalog sync — entries, categories, assets, products, variants
Detail views and purchases
Recommend to user, recommend to item
Twig API, element resolution, connection log, diagnostics
Console sync, dry run, preview payload
Cart additions, bookmarks, ratings, view portions
Guest → account merge
Recombee search
Item segments
Pagination (next())
Boosters, custom logic, diversity, expert settings, ReQL expressions
Attribution ledger and the recommendations report
Historic Commerce order backfill
Client-side recommendations endpoint

Lite is a real integration, not a teaser: detail views and purchases are the pair that make a recommender work at all. Everything in Pro sharpens it.

Events

use justinholtweb\bee\events\InteractionEvent;
use justinholtweb\bee\services\Interactions;
use yii\base\Event;

// Don't track staff.
Event::on(Interactions::class, Interactions::EVENT_BEFORE_RECORD, function(InteractionEvent $e) {
    $e->isValid = !Craft::$app->getUser()->getIsAdmin();
});
use justinholtweb\bee\events\RecommendationEvent;
use justinholtweb\bee\services\Recommendations;

// Merchandising rules that belong to the site rather than to the model.
Event::on(Recommendations::class, Recommendations::EVENT_AFTER_RECOMMEND, function(RecommendationEvent $e) {
    // $e->set is the RecommendationSet; $e->params were the request options.
});

Support

justin@justinholt.com