How to Build a Custom 'B2B Customer-Specific Catalog' Module in Magento 2
How to Build a Custom 'B2B Customer-Specific Catalog' Module in Magento 2
Let’s build a practical, maintainable B2B customer-specific catalog module for Magento 2. I’ll walk you through architecture choices, data structure, admin UI, integration with customer groups and catalog rules, indexing and performance tuning for thousands of products and clients, plus migration strategies from older solutions. I’ll keep it relaxed — like explaining the approach to a colleague who’s comfortable with Magento basics but hasn’t built a custom catalog system yet.
Why you’d build a customer-specific catalog
In many B2B setups, you need more than simple customer groups or catalog price rules. Customers may have bespoke prices, visibility restrictions, or product lists. Magento Commerce offers Shared Catalogs, but if you run Magento Open Source (or need different behavior), a custom module that maps overrides per customer (and per group) is the way to go.
High-level architecture
Here’s the scalable approach I recommend:
- Store only overrides (sparse matrix): only save rows for products with customer-specific or group-specific differences.
- Support multiple scopes: customer-specific, customer-group-specific, website-level, fallback to global price.
- Use custom tables with proper indexes for fast lookups and joins.
- Build an indexer that flattens effective price/visibility per product per audience used by storefront queries (optionally partial/index-on-read for very large data sets).
- Use plugins/observers to apply overrides at runtime where needed (price engines, product collections, search results).
- Admin UI: create a friendly grid + edit form for mass assignation and CSV import/export.
Data model: tables and fields
Important principle: do not duplicate Magento's entire price index. Only store differences and precompute flattened index if necessary.
Suggested custom tables (db_schema.xml based):
<table name="mf_customer_catalog_override" resource="default" engine="innodb" comment="Magefine customer catalog overrides">
<column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" />
<column xsi:type="int" name="customer_id" nullable="true" unsigned="true" />
<column xsi:type="int" name="customer_group_id" nullable="true" unsigned="true" />
<column xsi:type="int" name="product_id" nullable="false" unsigned="true" />
<column xsi:type="decimal" name="price_override" scale="4" precision="12" nullable="true" />
<column xsi:type="int" name="visibility" nullable="true" unsigned="true" comment="Use Magento catalog visibility constants" />
<column xsi:type="timestamp" name="updated_at" nullable="false" default="CURRENT_TIMESTAMP" />
<constraint xsi:type="primary" referenceId="PRIMARY">
<column name="id" />
</constraint>
<index referenceId="IDX_PRODUCT" indexType="BTREE" >
<column name="product_id" />
</index>
<index referenceId="IDX_CUSTOMER" indexType="BTREE" >
<column name="customer_id" />
</index>
<index referenceId="IDX_GROUP" indexType="BTREE" >
<column name="customer_group_id" />
</index>
</table>
Notes:
- Keep customer_id nullable because many overrides will be group-based.
- Only store overrides. If a row exists, that value takes precedence; otherwise fallback rule resolution applies.
Resolving price and visibility: precedence rules
Implement a clear precedence algorithm. Example order (highest to lowest):
- Customer-specific override
- Customer-group-specific override
- Website-specific catalog rule or price
- Global price / base product price
Code implementing this should be compact and cacheable. We’ll implement two things: a service class to compute effective values and a plugin to apply them during product price calculation and product collection queries.
Service: EffectiveCatalogProvider (example)
namespace Magefine2bCatalog\Model;
use Magento\\Customer\Model\Session as CustomerSession;
use Magento\Catalog\Model\ProductRepository;
class EffectiveCatalogProvider
{
protected $resource;
protected $connection;
protected $customerSession;
protected $productRepository;
public function __construct(
\Magento\Framework\App\ResourceConnection $resource,
CustomerSession $customerSession,
ProductRepository $productRepository
) {
$this->resource = $resource;
$this->connection = $resource->getConnection();
$this->customerSession = $customerSession;
$this->productRepository = $productRepository;
}
public function getOverrideForProduct($productId)
{
$customerId = $this->customerSession->getCustomerId();
$groupId = $this->customerSession->getCustomer()->getGroupId();
$table = $this->resource->getTableName('mf_customer_catalog_override');
// First try customer-specific
$select = $this->connection->select()
->from($table)
->where('product_id = ?', (int)$productId)
->where('customer_id = ?', (int)$customerId)
->limit(1);
$row = $this->connection->fetchRow($select);
if ($row) {
return $row;
}
// Then try group
$select = $this->connection->select()
->from($table)
->where('product_id = ?', (int)$productId)
->where('customer_group_id = ?', (int)$groupId)
->limit(1);
return $this->connection->fetchRow($select);
}
}
This service is simple and synchronous. For per-product requests this is okay, but you don’t want to run this for every product in a collection one-by-one without optimization. That’s where indexing and pre-join come in.
Plugin: override final price
One practical approach is to create an around plugin on Magento\Catalog\Model\Product\Type\Price::getFinalPrice(). That method is commonly used to determine product price on the storefront.
namespace Magefine\B2BCatalog\Plugin;
use Magefine\B2BCatalog\Model\EffectiveCatalogProvider;
use Magento\Catalog\Model\Product\Type\Price;
class PricePlugin
{
protected $provider;
public function __construct(EffectiveCatalogProvider $provider)
{
$this->provider = $provider;
}
public function aroundGetFinalPrice(Price $subject, \Closure $proceed, $qty, $product)
{
$result = $proceed($qty, $product);
$override = $this->provider->getOverrideForProduct((int)$product->getId());
if ($override && $override['price_override'] !== null) {
// if override is absolute price
return (float)$override['price_override'];
}
return $result;
}
}
That’s the simplest override. You might want percentage-based overrides or tier price merges — adapt the logic accordingly. Keep it small and cache results per request to avoid repeated DB hits.
Filtering product collections for visibility
If some products must be hidden for certain customers, the best approach is to alter the product collection SQL so the filtering happens in the database (fast) instead of filtering in PHP (slow).
Approach:
- Add a left join of the override table to the collection and filter out products where visibility says hidden for that customer or group.
- Prefer a dedicated flattened index table for store-front loads — otherwise joins on EAV collections get expensive.
namespace Magefine\B2BCatalog\Plugin\Collection;
use Magento\Catalog\Model\ResourceModel\Product\Collection;
use Magento\Framework\App\ResourceConnection;
use Magento\Customer\Model\Session as CustomerSession;
class CollectionPlugin
{
protected $resource;
protected $customerSession;
public function __construct(ResourceConnection $resource, CustomerSession $customerSession)
{
$this->resource = $resource;
$this->customerSession = $customerSession;
}
public function beforeLoad(Collection $subject, $printQuery = false, $logQuery = false)
{
$customerId = (int)$this->customerSession->getCustomerId();
$groupId = (int)$this->customerSession->getCustomer()->getGroupId();
$table = $this->resource->getTableName('mf_customer_catalog_override');
$connection = $this->resource->getConnection();
$select = $subject->getSelect();
// join override table to allow filtering
$select->joinLeft(
['mf_cco' => $table],
'e.entity_id = mf_cco.product_id AND (mf_cco.customer_id IS NULL OR mf_cco.customer_id = ' . $connection->quote($customerId) . ')',
[]
);
// filter where visibility overridden to 0 (example) or to a specific visibility
$select->where('IFNULL(mf_cco.visibility, 4) != ?', 1); // 1 = Not Visible Individually
return [$printQuery, $logQuery];
}
}
Notes:
- Be careful with SQL and aliasing: product collection alias for entity table is usually 'e'.
- Use IFNULL or COALESCE to fallback when there is no override.
- Test on layered navigation, search, and category pages: the collection join can affect counts. Keep the join selective and indexed.
Admin UI: intuitive management of catalogs
Admins should be able to:
- Create a customer-specific override
- Create group-level override rules
- Import and export overrides via CSV
- Bulk assign products to a customer catalog
Implementation sketch:
- Admin router and ACL entries.
- UI Component grid (ui_component xml) listing overrides with filters for customer / group / product.
- Edit form UI Component with product chooser (product via SKU or ID), customer autocomplete (adminhtml customer grid as chooser), and price/visibility fields.
- CSV import controller that processes rows in batches to avoid timeouts.
Example ui_component grid snippet (adminhtml):
<listing xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Ui:etc/ui_configuration.xsd">
<dataSource name="mf_customer_catalog_ds" />
<columns>
<column name="id" />
<column name="customer_id" />
<column name="customer_group_id" />
<column name="product_id" />
<column name="price_override" />
<column name="visibility" />
</columns>
</listing>
For the product chooser you can reuse Magento’s product modal (ui component) with a custom source to select multiple products on a single override action.
Bulk operations and CSV import
Design the CSV importer to run via cron or queue. Example flow:
- Admin uploads CSV
- Controller validates and stores the file in var/imports
- A consumer picks the file and processes rows in batches, using transactions and logging errors
- On completion, the indexer is launched (or partial index updated)
Batch size 500–2000 rows is common depending on row complexity. Always measure memory and CPU on your staging environment.
Indexer strategy and performance
This is a critical part. If you have thousands of customers and thousands of products, a naive per-customer-per-product index explodes. Consider these strategies:
- Flat overrides only: store overrides sparsely and join at runtime (works if overrides are rare).
- Partial precomputed index: build a per-product index for groups and common customers, and keep customer-only overrides in a small additional table applied at query time.
- Materialized view per website or group: smaller than per-customer and covers the most common cases.
- On-demand cache: compute effective product lists on first visit and keep cached with short TTL or purge on change. Works when most users don’t need real-time changes.
Technical tips:
- Leverage Magento’s Indexer framework to recompute only affected products/customers when overrides change.
- Use Redis for caching index lookups and to store temporary flattened data for fast reads.
- Use efficient DB indexes: composite indexes on (product_id, customer_id), (product_id, customer_group_id) and search-relevant fields.
- When joining to product collections, limit joins to category pages and product lists where necessary; avoid joining on global product listing queries.
Scalability patterns for thousands of clients/products
Examples and trade-offs:
- If overrides are < 5% of the catalog, store only overrides and apply a join or cache per session. This is memory efficient.
- If many customers have bespoke catalogs, precompute per-customer flattened tables but store them in a separate reporting DB or use sharded tables per customer segment to keep table sizes manageable.
- Consider using Elasticsearch filters for visibility when customers have divergent catalogs; ES can filter at search-time but requires index integration and a sync mechanism.
- Use HTTP caching carefully: full-page cache must vary by customer (often impossible) or you must use hole-punching (ESI) for price blocks. In practice, B2B stores often need authenticated caches or per-customer caches.
Full example: flow for rendering a category page
- Customer logs in — session has customer_id and group_id.
- Category page controller requests product collection for the category.
- Our Collection plugin modifies the SQL to join the override table (or selects from flattened index for current group/customer).
- Catalog price index / price plugin resolves overridden prices via precomputed value or via EffectiveCatalogProvider with Redis caching to avoid DB hits per product.
- Rendering uses final price source and shows/hides product based on resolved visibility.
Caching techniques
- Per-request cache: memoize EffectiveCatalogProvider lookups during a request.
- Redis/Memcached: cache effective price mappings per customer for X minutes. Invalidate on change via event-driven cache purge.
- Varnish/Full Page Cache: for customer-specific content, you can’t serve the same cached HTML to different customers unless catalogs are identical. Use ESI blocks for dynamic prices or keep authenticated pages uncached.
- Use TTL and version keys in cache keys to enable quick invalidation when an override is created or changed (e.g. mf_b2b_cat_v1_customer_123_product_456).
Integration with Magento customer groups and catalog rules
Keep integration layers small and predictable:
- Customer group overrides should be a first-class entity in your UI.
- When a Magento Catalog Price Rule or Cart Price Rule applies, you must decide how it interacts with per-customer pricing. Typical options:
- Customer-specific price overrides bypass catalog rules (override wins).
- Customer-specific price is combined with catalog rules (e.g. override as base price then rule applies percent discount).
- Document chosen behavior and make it configurable.
To respect Magento Catalog Rules when you have custom price overrides, either:
- Apply catalog rule logic during indexer when building your flattened index.
- Or keep override logic in the final price plugin and call Magento's rule engine to compute any additional discounts.
Security and ACL
Expose admin features behind ACL resources. Create capabilities like:
- magefine_b2b_catalog::manage_overrides
- magefine_b2b_catalog::import
Ensure import processes validate customer IDs and SKUs to prevent accidental data corruption. Run imports with a dry-run option.
Testing and QA
Key scenarios to test:
- Customer-specific price overrides applied on product pages, category pages, cart, and checkout.
- Customer-group overrides fallback correctly when no customer override exists.
- Import edge cases: missing SKUs, duplicate rows, invalid group IDs.
- Performance tests: cold cache and warm cache with thousands of customers and products.
- Concurrent updates: ensure indexer and cache invalidation work under concurrency.
Migration strategies from other solutions
Common migration sources are custom tables from older modules, CSVs or third-party services. The migration plan should be incremental:
- Inventory legacy data and map fields to the new schema. Typically legacy tables use different column names or mix group and customer overrides in one table.
- Write a mapping script that validates rows and normalizes values (e.g., visibility mapping, SKU → product_id resolution).
- Use batched insert with transactions to avoid long locks (INSERT ... ON DUPLICATE KEY UPDATE where supported).
- Bring in data to a staging table first and run consistency checks before moving to live table.
- Run an initial reindex and do an A/B comparison between old and new behavior using dedicated test accounts.
- Rollback plan: keep backups of legacy tables and a reversible migration script (use reversible SQL or keep a staging snapshot).
Handle conflicts: if both the legacy extension and the new module are active simultaneously during migration, ensure a clear precedence or disable legacy behavior for safety.
Additional implementation tips and gotchas
- Don’t put heavy logic in observers called many times (e.g. product save). Use async jobs for costly recalculations.
- When joining override tables, avoid SELECT *; instead specify needed columns to reduce data transfer.
- Monitor slow queries and add the right composite indexes as your query patterns stabilize.
- When deciding between row-level overrides and rule-based bulk overrides, prefer rules for reusability and fewer rows (e.g., apply a percentage to a group of SKUs rather than thousands of individual entries).
Example file list for the module (minimum)
- registration.php
- etc/module.xml
- etc/db_schema.xml (tables)
- etc/adminhtml/menu.xml, acl.xml
- etc/di.xml (service & plugin injections)
- Model/EffectiveCatalogProvider.php
- Plugin/PricePlugin.php
- Plugin/Collection/CollectionPlugin.php
- Ui/Component files for grid and form
- Controller/Adminhtml/Import/Upload.php
- Setup/RecurringIndexer (or indexer implementation)
SEO considerations for magefine.com
When publishing on magefine.com, keep SEO in mind:
- Meta title should be concise and include “Magento 2” and “B2B customer catalog”.
- Meta description should summarize value (how-to, performance, migration).
- URL key should be human-friendly and include keywords.
- Include structured headings (H2 / H3) and code examples for long-tail search queries.
- Internal linking: link to related Magefine resources (hosting, extensions you actually offer) — avoid linking to non-existent extensions.
Deployment and monitoring
Deploy indexers and importers as cron/queue consumers. Monitor metrics:
- Cache hit ratio (Redis)
- Slow queries and index rebuild time
- Error logs for import and indexing
- Frontend performance (page load, TTFB) for authenticated customers
Conclusion: trade-offs and recommended path
There’s no one-size-fits-all solution. My recommended path:
- Start with a sparse override table and a clean fallback precedence.
- Expose a simple admin UI and a CSV import with batch processing.
- Use a price plugin and collection join for correctness, and add Redis caching per customer to reduce DB pressure.
- Measure — if overrides become dense, add a flattened indexer and move precomputed data to a read-optimized table (or separate DB) to serve storefront traffic quickly.
If you want, I can provide a zip scaffold with the exact registration.php, module.xml, di.xml, sample db_schema.xml, the two plugins and the EffectiveCatalogProvider implementation. That would be a ready-to-install starting point and include basic unit and integration test examples.
Good luck — and ping me if you want the scaffold or help tuning the indexer for your catalog size. Building this the right way from the start saves you painful rewrites later.



