Recommender
The Recommender package is a collaborative filtering engine for Canvas that turns member ratings into personalised product recommendations. It implements two complementary algorithms — item-based co-occurrence and Slope One weighted prediction — on top of the CakePHP 5 database layer. Rate products, query recommendations, and predict ratings with no machine-learning infrastructure required.
Core Concepts
The engine is built around two database tables and a small set of service classes. Understanding how they relate makes it straightforward to integrate recommendations into any Canvas application.
- Ratings — Stored in
vogoo_ratings. A rating is a float in [0.0, 1.0] assigned by a member to a product within a category. The special value-1.0marks explicit disinterest. - Links — Stored in
vogoo_links. Pre-computed item pairs with a co-occurrence count (cnt) and a Slope One differential (diff_slope). These must be populated before recommendations can be served. - Categories — All operations are scoped to a category. A default category is set in config and used when none is supplied explicitly. This allows a single table pair to serve multiple product catalogues.
- RecommendationConfig — A value object holding all tunable constants. Injected via the Canvas DI container; no global configuration needed.
- RecommendationEngine — Handles rating CRUD. Optionally maintains
vogoo_linksincrementally on every write. - ItemRecommender — Item-based CF and Slope One recommendations for both logged-in members and anonymous visitors.
- UserSimilarity — Member similarity scoring and neighbour-based recommendations.
- VisitorContext — In-memory rating store for anonymous visitors, serialisable to session.
- Statistics — Catalogue-level counts and rankings.
Installation
Install via Composer:
composer require quellabs/recommender
Then publish the configuration file:
sculpt recommender:init
Edit config/recommender.php to tune the engine constants, then create the tables:
sculpt recommender:init-db
If you have existing ratings data, populate vogoo_links with a full rebuild:
sculpt recommender:rebuild-links
Quick Start
With Canvas (autowired)
RecommendationEngine and ItemRecommender are resolved automatically by the Canvas DI container — their constructors depend only on Connection (provided by quellabs/canvas-database) and RecommendationConfig (provided by this package), so no manual wiring is required:
use Quellabs\Recommender\RecommendationEngine;
use Quellabs\Recommender\ItemRecommender;
class ShopController {
public function __construct(
private RecommendationEngine $engine,
private ItemRecommender $recommender,
) {}
public function product(int $memberId, int $productId): Response {
// Record a view
$this->engine->automaticRating($memberId, $productId, purchase: false);
// Fetch recommendations
$recommended = $this->recommender->memberGetRecommendedItems($memberId, limit: 8);
return $this->render('product', compact('recommended'));
}
}
Standalone
use Cake\Database\Connection;
use Cake\Database\Driver\Mysql;
use Quellabs\Recommender\Config\RecommendationConfig;
use Quellabs\Recommender\RecommendationEngine;
use Quellabs\Recommender\ItemRecommender;
$connection = new Connection([
'driver' => Mysql::class,
'host' => 'localhost',
'username' => 'root',
'password' => '',
'database' => 'mydb',
]);
$config = new RecommendationConfig();
$engine = new RecommendationEngine($connection, $config);
$recommender = new ItemRecommender($connection, $config);
Configuration
All options with their defaults:
// config/recommender.php
return [
// Default category used when no category is passed to engine methods
'category' => 1,
// Minimum number of common ratings before similarity is considered reliable
'threshold_nr_common_ratings' => 30,
// Multiplier used in the similarity confidence calculation
'threshold_mult' => 2,
// Minimum rating for an item to count as "liked" in link calculations
'threshold_rating' => 0.66,
// Cost factor used in the member similarity spread calculation
'cost' => 5.0,
// Sentinel value stored to mark "not interested" (must be negative)
'not_interested' => -1.0,
// Maintain vogoo_links incrementally on every rating change.
// When false, run "sculpt recommender:rebuild-links" after bulk imports.
'direct_links' => false,
'direct_slope' => true,
];
Database credentials are read from config/database.php, which is shared with other Canvas packages. The Sculpt provider lists both files as config sources in composer.json.
direct_links and direct_slope are independent switches that control which columns of vogoo_links are maintained in real time:
| Flag | Powers | Counts |
|---|---|---|
direct_links | getLinkedItems(), memberGetRecommendedItems() | Co-occurrence — liked pairs only |
direct_slope | getSlopeItems(), memberPredict(), memberPredictAll() | All rated pairs |
When both are false, vogoo_links is read-only at runtime and must be rebuilt manually after bulk rating imports.
Database Schema
vogoo_ratings
| Column | Type | Description |
|---|---|---|
member_id | INT UNSIGNED | Your application's user ID |
product_id | INT UNSIGNED | Your application's product/item ID |
category | INT UNSIGNED | Category grouping (default: 1) |
rating | FLOAT | 0.0–1.0, or -1.0 for "not interested" |
ts | DATETIME | Last updated timestamp |
vogoo_links
| Column | Type | Description |
|---|---|---|
item_id1 | INT UNSIGNED | First item in the pair |
item_id2 | INT UNSIGNED | Second item in the pair |
category | INT UNSIGNED | Category grouping |
cnt | INT | Co-occurrence count (used by item-based CF) |
diff_slope | FLOAT | Accumulated Slope One rating differential |
vogoo_links is (item_id1, item_id2, category). This also serves as the unique constraint required by the rebuild command's upsert. No separate unique index is needed.
RecommendationEngine
Handles all rating reads and writes. When direct_links or direct_slope is enabled, write operations also update vogoo_links incrementally.
Recording Ratings
// Explicit rating — must be in [0.0, 1.0]
$engine->setRating($memberId, $productId, 0.8);
// Implicit rating from a purchase (1.0) or a click (0.7, or +0.01 if already rated)
$engine->automaticRating($memberId, $productId, purchase: true);
$engine->automaticRating($memberId, $productId, purchase: false);
// Mark as "not interested"
$engine->setNotInterested($memberId, $productId);
Reading Ratings
// Single rating — returns ['rating' => float, 'ts' => string] or []
$engine->getRating($memberId, $productId);
// Include "not interested" entries
$engine->getRating($memberId, $productId, notInterested: true);
// All ratings for a member, sorted by rating descending
$engine->memberRatings($memberId, orderByRating: true, ascending: false);
// Member statistics
$engine->memberNumRatings($memberId);
$engine->memberAverageRating($memberId);
// Product statistics
$engine->productNumRatings($productId);
$engine->productAverageRating($productId);
$engine->productRatings($productId);
Deleting Ratings
$engine->deleteRating($memberId, $productId);
$engine->deleteMember($memberId); // removes all ratings for this member
$engine->deleteProduct($productId); // removes all ratings for this product
direct_links or direct_slope is enabled, deleteMember() and deleteProduct() iterate over each rating individually to keep vogoo_links consistent. For large datasets, disable incremental updates and run a full rebuild instead.
ItemRecommender
Provides item-based CF and Slope One recommendations for both logged-in members and anonymous visitors.
Item-based CF
// Products that co-occur most frequently with a given product
$recommender->getLinkedItems($productId);
$recommender->getLinkedItems($productId, limit: 10);
// Recommended unrated products for a member, ordered by weighted co-occurrence
$recommender->memberGetRecommendedItems($memberId);
$recommender->memberGetRecommendedItems($memberId, limit: 8);
// Restrict to a specific set of product IDs (e.g. current stock)
$recommender->memberGetRecommendedItems($memberId, filter: $availableIds, limit: 8);
// "Why we recommend this" — member's rated products linked to the given product
$recommender->memberGetReasons($memberId, $productId);
Slope One
// Predicted rating for a single product
$predicted = $recommender->memberPredict($memberId, $productId); // float|null
// All unrated products with predicted ratings, sorted best-first
$predictions = $recommender->memberPredictAll($memberId);
// [['product_id' => 42, 'rating' => 0.87], ...]
// Items ranked by average slope one diff relative to a product
$similar = $recommender->getSlopeItems($productId, minLinks: 5, limit: 10);
// [['product_id' => 17, 'diff' => 0.12], ...]
Anonymous Visitors
For visitors without an account, hold ratings in a VisitorContext and pass it to the visitor methods. Persist the context across requests via session serialisation.
// Create and populate a visitor context
$visitor = new VisitorContext($config);
$visitor->setRating($productId, 0.9);
$visitor->setNotInterested($otherProductId);
// Item-based CF for a visitor
$recommended = $recommender->visitorGetRecommendedItems($visitor, limit: 8);
$reasons = $recommender->visitorGetReasons($visitor, $productId);
// Slope One for a visitor
$predicted = $recommender->visitorPredict($visitor, $productId); // float|null
$allPredicted = $recommender->visitorPredictAll($visitor, limit: 8);
UserSimilarity
Computes member-to-member similarity scores using a weighted mean-squared-error algorithm. Similarity ranges from 0 (no overlap or completely different taste) to 100 (identical).
$similarity = $userSimilarity->memberSimilarity($memberId1, $memberId2); // int 0–100
// Neighbours sorted by similarity, filtered by minimum score
$neighbours = $userSimilarity->getNeighbours($memberId, minSimilarity: 10, limit: 20);
// [['member_id' => 7, 'similarity' => 84], ...]
// Recommendations based on what similar members have liked
$recommended = $userSimilarity->memberGetRecommendedItems($memberId, minSimilarity: 20, limit: 8);
getNeighbours() computes a similarity score against every member who shares at least one rated product. For catalogues with many members, pre-compute and cache neighbour lists offline rather than calling this on every request.
VisitorContext
An in-memory rating store for anonymous visitors. All operations are category-scoped. Serialise to session to persist across requests.
$visitor = new VisitorContext($config);
$visitor->setRating($productId, 0.9);
$visitor->setRating($productId, 0.5, category: 2); // explicit category
$visitor->setNotInterested($productId);
$visitor->removeRating($productId);
$visitor->getRatings(); // all ratings in default category
$visitor->getRatedProductIds(); // product IDs only
$visitor->isEmpty(); // true when no ratings exist
Statistics
$stats->numMembers(); // distinct members with at least one rating
$stats->members(); // array of member IDs
$stats->numProducts(); // distinct rated products
$stats->numRatings(); // total genuine ratings
$stats->numLinks(); // item pairs in vogoo_links
// Most-rated products: [['product_id' => int, 'num_ratings' => int], ...]
$stats->mostRatedProducts(limit: 10);
// Top-rated products: [['product_id' => int, 'avg_rating' => float], ...]
$stats->topRatedProducts(limit: 10, minRatings: 5);
Multi-category Support
Every method accepts an optional ?int $category parameter. When omitted it falls back to the default set in RecommendationConfig. To serve multiple independent catalogues, either pass the category explicitly on each call, or instantiate separate config objects per catalogue:
$booksConfig = new RecommendationConfig(category: 1);
$moviesConfig = new RecommendationConfig(category: 2);
$booksEngine = new RecommendationEngine($connection, $booksConfig);
$moviesEngine = new RecommendationEngine($connection, $moviesConfig);
Sculpt CLI Commands
| Command | Description |
|---|---|
sculpt recommender:init | Publish config/recommender.php to the project root |
sculpt recommender:init-db | Create vogoo_ratings and vogoo_links tables |
sculpt recommender:init-db --force | Drop and recreate existing tables (all data will be lost) |
sculpt recommender:rebuild-links | Rebuild vogoo_links from all ratings data |
sculpt recommender:rebuild-links --category=N | Rebuild a single category only |
Ratings Reference
| Value | Meaning | Set via |
|---|---|---|
1.0 | Purchased / maximum interest | automaticRating(purchase: true) |
0.7 | Clicked — first view | automaticRating(purchase: false) |
0.7 + 0.01 per click | Incremental interest from repeat clicks | automaticRating(purchase: false) |
0.0 – 1.0 | Explicit rating | setRating() |
-1.0 | Not interested | setNotInterested() |