How to Build a Custom 'Product Comparison by Attribute' Module in Magento 2
Why build a custom "Product Comparison by Attribute" module in Magento 2?
We all know Magento 2 comes with a basic product compare feature. It’s fine for simple stores, but when you need attribute-based comparisons (think comparing technical specs across many SKUs), you’ll want full control: which attributes show, how rows are grouped, how comparisons are stored and exported, and how the UI behaves on mobile.
In this post I’ll walk you through building a custom module that:
- Creates custom product attributes and a dedicated comparison table in the database.
- Provides a responsive AJAX-driven comparison block for the frontend.
- Integrates comparison buttons in category lists and product pages.
- Shows performance patterns and optimizations for large catalogs.
- Explains extension points: exporting comparisons and auto-suggestion of similar products.
High-level architecture
Think of three main layers:
- Data layer: a custom table to store saved comparisons and product-to-comparison links, plus product EAV attributes that users compare.
- Business layer: models, repositories and services to manipulate comparisons and generate comparison matrices by attributes.
- Presentation layer: a block with a phtml template and JS that fetches and updates the comparison via REST-like controller endpoints (AJAX).
Module skeleton
Create a module named Magefine_ProductCompare (replace Magefine with your vendor if you want). Folder structure (app/code/Magefine/ProductCompare):
app/code/Magefine/ProductCompare/
├── etc
│ ├── module.xml
│ ├── frontend
│ │ └── routes.xml
│ ├── db_schema.xml
│ └── di.xml
├── registration.php
├── Composer.json (optional)
├── Setup
│ └── Patch
│ └── Data
│ └── AddComparisonAttributes.php
├── Model
│ ├── Comparison.php
│ ├── ResourceModel
│ │ └── Comparison.php
│ │ └── Collection.php
├── Controller
│ └── Ajax
│ ├── Add.php
│ ├── Remove.php
│ └── GetMatrix.php
├── view
│ └── frontend
│ ├── templates
│ │ └── compare/block.phtml
│ └── web
│ └── js
│ └── compare.js
└── etc
└── acl.xml (if you need admin endpoints)
1) Data layer: creating a comparison table and product attributes
We will create a dedicated table to store comparisons metadata and a linking table to store which products are in a comparison list. Use Magento's declarative schema (db_schema.xml):
<?xml version="1.0"?>
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
<table name="magefine_product_compare" resource="default" engine="innodb" comment="Magefine product comparisons">
<column xsi:type="int" name="compare_id" unsigned="true" nullable="false" identity="true" comment="Compare ID"/>
<column xsi:type="varchar" name="session_id" nullable="true" length="255" comment="Session or user identifier"/>
<column xsi:type="int" name="customer_id" nullable="true" unsigned="true" comment="Customer ID"/>
<column xsi:type="timestamp" name="created_at" nullable="false" default="CURRENT_TIMESTAMP" comment="Created At"/>
<constraint referenceId="PRIMARY" type="primary" columns="compare_id"/>
</table>
<table name="magefine_product_compare_item" resource="default" engine="innodb" comment="Products inside a comparison">
<column xsi:type="int" name="item_id" unsigned="true" nullable="false" identity="true"/>
<column xsi:type="int" name="compare_id" unsigned="true" nullable="false"/>
<column xsi:type="int" name="product_id" unsigned="true" nullable="false"/>
<constraint referenceId="PRIMARY" type="primary" columns="item_id"/>
<constraint referenceId="CMP_COMPARE_ID" type="foreign" referenceTable="magefine_product_compare" referenceColumn="compare_id" columns="compare_id" onDelete="CASCADE"/>
</table>
</schema>
This gives us a flexible way to persist comparisons for logged in users or sessions.
Adding product attributes (data patch)
We want admin-selectable attributes to appear in comparisons (e.g., screen_size, battery_capacity, color). Add a data patch that creates a boolean attribute “is_comparable” for attributes or mark existing attributes to be used in compare lists. For clarity, we’ll add a product attribute called "is_comparable" that can be used when creating attribute groups in the admin.
<?php
namespace Magefine\ProductCompare\Setup\Patch\Data;
use Magento\Eav\Setup\EavSetupFactory;
use Magento\Framework\Setup\Patch\PatchInterface;
use Magento\Catalog\Model\Product;
class AddComparisonAttributes implements PatchInterface
{
private $eavSetupFactory;
public function __construct(EavSetupFactory $eavSetupFactory)
{
$this->eavSetupFactory = $eavSetupFactory;
}
public function apply()
{
$eavSetup = $this->eavSetupFactory->create();
// This creates a simple attribute to mark attributes as comparable in admin
$eavSetup->addAttribute(
Product::ENTITY,
'is_comparable',
[
'type' => 'int',
'label' => 'Is Comparable (flag for comparison UI)',
'input' => 'boolean',
'backend' => '',
'required' => false,
'sort_order' => 100,
'global' => \Magento\Catalog\Model\ResourceModel\Eav\Attribute::SCOPE_GLOBAL,
'visible' => true,
'user_defined' => true,
'default' => 0,
'group' => 'General',
]
);
}
public static function getDependencies() {return [];}
public function getAliases() {return [];}
}
Note: for production, you might want to add a separate EAV attribute definition that classifies attributes (e.g., product_attribute.is_comparable) or create a configuration entity that stores attribute codes to compare. One recommended approach: create an admin UI where the store manager picks which attribute codes appear in the comparison matrix (we’ll show later how that list is read).
2) Business layer: models, resource models and repository
Keep the model simple. Comparison model handles basic CRUD via resource model. Example files are minimal for clarity.
<?php
namespace Magefine\ProductCompare\Model;
use Magento\Framework\Model\AbstractModel;
class Comparison extends AbstractModel
{
protected function _construct()
{
$this->_init('Magefine\ProductCompare\Model\ResourceModel\Comparison');
}
}
// ResourceModel/Comparison.php
namespace Magefine\ProductCompare\Model\ResourceModel;
use Magento\Framework\Model\ResourceModel\Db\AbstractDb;
class Comparison extends AbstractDb
{
protected function _construct()
{
$this->_init('magefine_product_compare', 'compare_id');
}
}
Manage items in magefine_product_compare_item with a separate resource model. Keep heavy queries in resource models or dedicated services and not in controllers.
3) Presentation layer: routes, controller endpoints and block
We’ll expose three AJAX endpoints: add a product to comparison, remove a product, and return the comparison matrix (HTML or JSON). Add routes in etc/frontend/routes.xml:
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:App/etc/routes.xsd">
<router id="standard">
<route id="magefine_compare" frontName="magefine_compare">
<module name="Magefine_ProductCompare"/>
</route>
</router>
</config>
Example controller: Add.php
<?php
namespace Magefine\ProductCompare\Controller\Ajax;
use Magento\Framework\App\Action\Action;
use Magento\Framework\App\Action\Context;
use Magento\Catalog\Model\ProductFactory;
use Magento\Customer\Model\Session as CustomerSession;
use Magento\Framework\Serialize\Serializer\Json;
class Add extends Action
{
private $productFactory;
private $customerSession;
private $jsonSerializer;
public function __construct(Context $context, ProductFactory $productFactory, CustomerSession $customerSession, Json $json)
{
parent::__construct($context);
$this->productFactory = $productFactory;
$this->customerSession = $customerSession;
$this->jsonSerializer = $json;
}
public function execute()
{
$result = ['success' => false];
$productId = (int)$this->getRequest()->getParam('product_id');
if ($productId) {
// Simplified: store in session for non-logged users, or in table for logged users
$compare = $this->getRequest()->getParam('compare_id');
// Load or create comparison by session/customer..., then add product id into compare_item table via resource model
$result['success'] = true;
$result['message'] = __('Product added to comparison');
}
/** @var \Magento\Framework\Controller\Result\Raw $response */
$response = $this->resultFactory->create(\Magento\Framework\Controller\ResultFactory::TYPE_RAW);
$response->setHeader('Content-type', 'application/json');
$response->setContents($this->jsonSerializer->serialize($result));
return $response;
}
}
This controller is intentionally concise. Keep business logic in a service class that handles merging session comparisons with customer comparisons when a user logs in.
Block and template
Create a block that returns a rendered comparison matrix. The template (view/frontend/templates/compare/block.phtml) will have both the table and hooks for AJAX updates. Example template:
<?php /** @var $block \Magefine\ProductCompare\Block\Compare */ ?>
<div id="magefine-compare" class="magefine-compare-block">
<div class="mf-compare-header">
<button id="mf-clear-compare" class="action secondary">Clear</button>
<span class="mf-count"><?= $block->getProductCount() ?> items</span>
</div>
<div id="mf-compare-matrix" class="mf-compare-matrix">
<?= $block->getMatrixHtml() ?>
</div>
</div>
JS: ajax interactions
Simple jQuery-based script (view/frontend/web/js/compare.js). The script listens for add/remove events and reloads the matrix via AJAX.
define(['jquery'], function($) {
'use strict';
var endpoints = {
add: '/magefine_compare/ajax/add',
remove: '/magefine_compare/ajax/remove',
matrix: '/magefine_compare/ajax/getmatrix'
};
function refreshMatrix() {
$('#mf-compare-matrix').load(endpoints.matrix);
}
$(document).on('click', '.mf-add-to-compare', function(e) {
e.preventDefault();
var pid = $(this).data('product-id');
$.post(endpoints.add, {product_id: pid}, function(resp) {
if (resp.success) refreshMatrix();
else alert(resp.message || 'Error');
}, 'json');
});
$(document).on('click', '.mf-remove-from-compare', function(e) {
e.preventDefault();
var pid = $(this).data('product-id');
$.post(endpoints.remove, {product_id: pid}, function(resp) {
if (resp.success) refreshMatrix();
}, 'json');
});
$(document).ready(function() {
refreshMatrix();
});
});
4) Building the comparison matrix
The matrix generation is the critical part. We must query product attributes only once per attribute and get scalar values for many products. Avoid loading full product models; use product collections and addAttributeToSelect for attributes chosen. Example resource-model method pseudocode:
public function getAttributeMatrix(array $productIds, array $attributeCodes)
{
// Use product collection
$collection = $this->productCollectionFactory->create();
$collection->addAttributeToFilter('entity_id', ['in' => $productIds]);
$collection->addAttributeToSelect($attributeCodes);
$matrix = [];
foreach ($collection as $product) {
$row = [];
foreach ($attributeCodes as $code) {
$row[$code] = $product->getData($code);
}
$matrix[$product->getId()] = $row;
}
return $matrix;
}
For readable presentation you’ll want attribute labels and option labels for select attributes. Use the EAV attribute repository to get frontend labels or attribute options.
Render strategy (HTML vs JSON)
Rendering server-side HTML is fast for small matrices and easy to integrate with .load(). For very large comparisons (dozens of products & attributes) consider returning JSON and rendering the table via client-side templating for smoother UX.
5) Frontend UX integration
Where to place compare actions:
- Category/product list templates: inject a button near product actions (add to cart, wishlist).
- Product view: provide a comparably styled button or sticky toolbar.
- Mini-compare floating block: a small bottom drawer that shows selected products and a "compare now" CTA.
Example layout injection to add the compare block to the sidebar (layout xml):
<referenceContainer name="sidebar.additional">
<block class="Magefine\ProductCompare\Block\Compare" name="magefine.compare.block" template="Magefine_ProductCompare::compare/block.phtml"/>
</referenceContainer>
Design & Accessibility tips
- Make sure the matrix is horizontally scrollable on mobile (use CSS overflow-x: auto on the table wrapper).
- Use aria-labels for add/remove buttons and keyboard-accessible controls.
- Cache matrix HTML fragments for anonymous users with cache keys based on session id, and vary by customer group for logged-in users.
6) Performance: optimizing queries & caching for large catalogs
Performance is the tricky bit for big catalogs. Key recommendations:
- Don't load full product models in loops. Use collections and addAttributeToSelect with only necessary attributes.
- Use resource model SQL for heavy joins; EAV joins can be expensive when you ask for many attributes. Use MIN/MAX/COALESCE patterns or pivot queries where possible.
- Create proper indexes on your comparison tables (compare_id, product_id). Add index on session_id if you store by session.
- Cache the comparison matrix for anonymous users and refresh on add/remove. Use Redis or Varnish for caching fragments or full pages. Magento block caching with cache keys and lifetimes helps a lot.
- Consider pre-computing attribute matrices for commonly compared product groups with cron jobs. This is useful when you have fixed comparison sets (e.g., top CPUs compared by specs), or in B2B where comparisons are reused.
Database tips
Sample SQL to speed up queries (add these indexes using db_schema.xml):
<index referenceId="IDX_COMPARE_PRODUCT" indexType="btree" columns="product_id" />
<index referenceId="IDX_COMPARE_SESSION" indexType="btree" columns="session_id" />
When reading many attributes for many products, EAV performs many joins. If your comparisons are heavy, create a denormalized table that stores attribute_code, product_id, and value_text/value_int. This table is much faster for pivoting because you can query values by attribute_code and product_id in a single pass and then assemble the matrix in PHP.
Avoiding N+1
Never request attribute labels inside a product loop. Fetch attribute metadata (frontend_label, options) for all attribute codes at once and then map option values to labels using lookup arrays.
7) UX integration details: product lists & product pages
Small snippets to insert the compare button into list and view templates. Example for product/list.phtml or a custom template override:
<div class="product-item-actions"
data-role="product-item-actions"
>
<button class="action mf-add-to-compare" data-product-id="<?= $_product->getId() ?>" aria-label="Add to compare">
<span>Compare</span>
</button>
</div>
On the product detail page, include a similar button; trigger a small animation that adds product thumbnail to the floating compare block to give feedback to the user.
8) Extensions you can add later
Once the basic module is in place, consider these useful extensions:
- Export comparisons: add a controller action that returns CSV or XLSX of the matrix. Implement pagination for huge exports and optionally queue a background export job and notify the user when ready.
- Auto-suggestions: when a user adds product A and B, automatically suggest product C that matches attribute patterns. Implement a simple scoring algorithm: count attribute matches, weight important attributes higher, and return top N similar products. For large catalogs, precompute similarity signatures (hashes) per product and do approximate nearest-neighbor searches.
- Admin UI for attribute selection: a system configuration page where the merchant picks which attribute codes appear in comparison lists (and the order).
- Import/Export of comparison definitions: allow merchants to save named comparison sets and export or share them via URL.
Example: export as CSV controller
<?php
namespace Magefine\ProductCompare\Controller\Ajax;
use Magento\Framework\App\Action\Action;
use Magento\Framework\App\Action\Context;
use Magento\Framework\Csv\Csv;
class Export extends Action
{
private $csv;
private $comparisonService;
public function __construct(Context $context, Csv $csv, \Magefine\ProductCompare\Model\ComparisonService $comparisonService)
{
parent::__construct($context);
$this->csv = $csv;
$this->comparisonService = $comparisonService;
}
public function execute()
{
$compareId = (int)$this->getRequest()->getParam('compare_id');
$matrix = $this->comparisonService->getMatrixForCompareId($compareId);
// Convert matrix to rows: first line headers
$header = array_merge(['product_id', 'sku', 'name'], array_keys(reset($matrix)));
$rows = [];
foreach ($matrix as $productId => $attrs) {
$row = array_merge([$productId, $attrs['sku'] ?? '', $attrs['name'] ?? ''], $attrs);
$rows[] = $row;
}
$data = array_merge([$header], $rows);
$csvContent = $this->csv->getDataAsString($data);
$this->getResponse()->setHeader('Content-Type', 'text/csv');
$this->getResponse()->setHeader('Content-Disposition', 'attachment; filename="comparison_'.time().'.csv"');
$this->getResponse()->setBody($csvContent);
}
}
9) Auto-suggestion algorithm (basic)
Simple approach: treat each comparable attribute as a feature and compute a similarity score between the current selection and candidate products. For small catalogs you can do this in SQL; for large ones precompute vectors and use approximate nearest neighbors (ANN) libraries.
// Pseudocode: simple attribute-match scoring
function suggestSimilarProducts($selectedProductIds, $limit = 5) {
// 1. Get attribute set for all products in selected set
$selectedAttrs = getCombinedAttributes($selectedProductIds); // returns map attribute=>value frequency
// 2. For each candidate product (exclude selected ids), compute score
// score = sum(weight(attr) * matchScore(candidate[attr], selectedAttrs[attr]))
// 3. Return top N products with highest score
}
Weight important attributes higher (e.g., processor_family more weight than color). For more advanced use, use vector embeddings of categorical attributes and cosine similarity.
10) Security and data hygiene
- Always validate product IDs and compare permissions in controllers.
- Rate limit expensive endpoints or use a small throttle for anonymous users to prevent DB spikes.
- When storing session-based comparisons, expire old records (cron) to avoid unbounded growth.
11) Testing and QA
Write unit tests for your comparison service and integration tests for controller endpoints. Test with both simple and large attribute sets. Measure memory and SQL time under load and fix N+1 problems early.
12) Deployment & configuration
Use the standard Magento deploy pipeline:
- composer update / module registration
- php bin/magento setup:upgrade (declarative schema will create tables)
- php bin/magento setup:di:compile
- php bin/magento setup:static-content:deploy
- flush cache
Provide a system.xml if you want site admins to configure default attributes and cache TTLs for the compare block.
13) Example: end-to-end quick flow
- Admin marks attributes as comparable or configures a list in system.xml.
- User clicks "Compare" on product list pages. JS posts product_id to /magefine_compare/ajax/add.
- Server adds product to session-based comparison and returns success.
- Client refreshes the compare matrix container via AJAX; server renders HTML matrix using product collection and attribute list.
- User clicks "Export CSV"; controller builds matrix and returns CSV download or creates a queued job if the export is large.
14) Real-life performance checklist
- Use Redis for session storage (keeps session-based comparisons quick).
- Enable Redis or Varnish for caching responses. Use proper cache tags to invalidate matrix when product attributes change.
- Precompute heavy joins for commonly compared sets via cron and store denormalized snapshots.
- Limit the UI to a reasonable number of products in a single comparison (e.g., max 6–8) if rendering client-side. Allow an "advanced comparison" flow for power users that handles more products asynchronously.
15) Final thoughts & next steps
This module is a balance between UX, flexibility and performance. Start simple: implement session-based comparison, server-rendered matrix and a manageable set of attributes. Then add admin UI for attribute selection, export, and auto-suggestion when you have a stable foundation.
If you’re using Magefine for extensions or hosting, you’ll appreciate how a custom-built compare module gives you a tailored experience for your catalog and customers. The same modular architecture can be extended to product spec sheets, automated recommendations and advanced analytics.
Appendix: Useful helpers and references
- Magento 2 developer docs — EAV attribute management and declarative schema.
- Use Magento's collection factories and avoid loading full product models in loops.
- Consider using Fastly/Varnish, Redis and PHP-FPM tuning for high concurrency.
If you want, I can: provide the full module files ready to drop into app/code, produce the admin configuration for attribute selection, or give a sample similarity algorithm tuned for 50k products. Tell me which one you prefer and I’ll generate the code.
Happy coding — and don’t hesitate to ping me if you want a code review once you have an initial version.




