How to Build a Custom Request for Quote (RFQ) Module for Complex B2B Products in Magento 2

If you’re building B2B flows in Magento 2, chances are you’ll need a proper Request for Quote (RFQ) system at some point. In this post I’ll walk you through how to build a custom RFQ module for complex B2B products in Magento 2 — architecture, DB entities, multi-level approval workflows, advanced product configuration handling, automated email notifications, and an admin dashboard to manage and analyze RFQs. Think of this as a practical, code-first walkthrough you can adapt to your store or extension.
Why a custom RFQ module?
Built-in Magento pricing and cart flows are great for standard retail. But B2B RFQs often need:
- Structured RFQ entities (requests, items, created quotes)
- Multi-level approval (procurement, finance, management)
- Support for configurable and highly-customized products
- Conditional pricing and custom price calculators
- Automated notifications and reporting
A custom module gives you the control to handle these. Below we’ll architect entities, show the critical XML/PHP files, and give code examples you can copy and adapt.
High-level architecture
Keep it simple and decoupled:
- DB layer: dedicated tables for rfq_request, rfq_item, rfq_quote, rfq_approval, rfq_history
- Service layer: repositories, factories, models for RFQ operations
- Workflow engine: small approval engine that advances stages and triggers notifications
- Product integration: capture product configuration (options, super_attributes) in item payload
- Admin UI: grids and charts for managing and analyzing RFQs
- Notification system: email templates + queueing + cron fallback
Database design — entities and schema
Use declarative schema (db_schema.xml) rather than InstallSchema. Keep the main entities normalized and store product configuration in JSON where needed.
Key tables
- rfq_request: meta for an RFQ (request_id, customer_id, company_id, status, created_at, updated_at)
- rfq_item: items in each RFQ (item_id, request_id, product_id, sku, qty, configurator_json, base_price, requested_price)
- rfq_quote: store versions of quotes returned (quote_id, request_id, quote_data_json, total, valid_until, created_by)
- rfq_approval: tracks approval steps (approval_id, request_id, approver_id, level, status, comments, acted_at)
- rfq_history: timeline events (history_id, request_id, event_type, data, created_at)
Example db_schema.xml snippet
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
<table name="rfq_request" resource="default" engine="innodb" comment="RFQ Requests">
<column xsi:type="int" name="request_id" unsigned="true" nullable="false" identity="true" />
<column xsi:type="int" name="customer_id" unsigned="true" nullable="true" />
<column xsi:type="varchar" name="status" nullable="false" length="50" />
<column xsi:type="timestamp" name="created_at" nullable="false" default="CURRENT_TIMESTAMP" />
<column xsi:type="timestamp" name="updated_at" nullable="true" />
<constraint xsi:type="primary" referenceId="PRIMARY">
<column name="request_id"/>
</constraint>
</table>
<!-- rfq_item, rfq_quote, rfq_approval, rfq_history defined similarly; rfq_item contains a configurator_json TEXT field for product options -->
</schema>
Storing product configuration as JSON in rfq_item keeps the RFQ safe from later product changes — the RFQ represents the product state at the time of request.
Module basics: registration and module.xml
Start with the usual files:
// registration.php
<?php
\Magento\Framework\Component\ComponentRegistrar::register(
\Magento\Framework\Component\ComponentRegistrar::MODULE,
'Magefine_Rfq',
__DIR__
);
// etc/module.xml
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
<module name="Magefine_Rfq" setup_version="1.0.0" />
</config>
Models, ResourceModels and Repositories
Follow Magento best practices: expose repositories for your aggregate root (rfq_request). Keep simple models and resource models for items and approvals.
Example Request model skeleton
// Model/Request.php
namespace Magefine\Rfq\Model;
use Magento\Framework\Model\AbstractModel;
class Request extends AbstractModel
{
protected function _construct()
{
$this->_init('Magefine\Rfq\Model\ResourceModel\Request');
}
}
// ResourceModel/Request.php
namespace Magefine\Rfq\Model\ResourceModel;
use Magento\Framework\Model\ResourceModel\Db\AbstractDb;
class Request extends AbstractDb
{
protected function _construct()
{
$this->_init('rfq_request', 'request_id');
}
}
// Api/RequestRepositoryInterface.php (sketch)
interface RequestRepositoryInterface
{
public function save(\Magefine\Rfq\Api\Data\RequestInterface $request);
public function getById($id);
public function getList(\Magento\Framework\Api\SearchCriteriaInterface $criteria);
public function delete(\Magefine\Rfq\Api\Data\RequestInterface $request);
}
Implementing the repository is boilerplate — inject the resource model, collection, and use SearchResultsFactory for getList.
Capturing complex product configuration
Complex B2B products could be configurable products, bundles, or products with custom options. The trick is to capture enough information at request time to recreate the request later and to price accordingly.
What to store in rfq_item.configurator_json
- product_type (configurable, simple, bundle)
- selected_super_attributes (for configurable)
- bundle option selections
- custom options and their values
- any uploaded spec files or technical details (store references to media)
// example configurator_json
{
"product_type": "configurable",
"product_id": 123,
"sku": "PROD-123",
"super_attributes": {"93": "167", "134": "402"},
"options": {"custom_text": "Please ship by air"}
}
When an admin builds a quote, they can rehydrate this JSON to show the exact product summary.
Conditional pricing and advanced price calculator
B2B RFQ pricing is rarely a simple multiply of base price × qty. You’ll want a price engine that can apply:
- Attribute-based rules (e.g., material = stainless increases cost)
- Quantity breaks and tier pricing
- Customer-specific discounts/markup
- One-time setup fees or conditional surcharges
Example price calculator concept
class PriceCalculator
{
public function calculate(array $itemData, $customerId)
{
$base = $itemData['base_price'];
$qty = $itemData['qty'];
// tier pricing example
if ($qty >= 100) {
$base *= 0.85; // 15% off
} elseif ($qty >= 50) {
$base *= 0.92;
}
// attribute based
if (!empty($itemData['configurator']['material']) && $itemData['configurator']['material'] === 'stainless') {
$base += 10; // extra per unit
}
// customer specific
$customerMargin = $this->getCustomerMargin($customerId); // returns e.g. 1.05
$price = $base * $customerMargin;
return round($price * $qty, 2);
}
}
Store the calculated price in the rfq_quote table; keep request level unmodified so you can compare vendor quotes to the original requested price.
Implementing a multi-level approval workflow
Approvals are central to B2B procurement. Keep the engine independent — a small approval table and a service that knows the approval chain is enough for most stores.
Approval design
- rfq_approval holds an ordered list of required approvers and status per level
- ApprovalService moves the request from one level to the next, writes history, and triggers notifications
- Admins can configure approvers per company or request type
// simplified ApprovalService
class ApprovalService
{
public function advance($requestId, $actorId, $comments = '')
{
$current = $this->approvalRepo->getCurrentByRequestId($requestId);
if (!$current) {
throw new \Exception('No approval steps configured');
}
// mark current as approved
$current->setStatus('approved');
$current->setActedAt(date('Y-m-d H:i:s'));
$current->setComments($comments);
$this->approvalRepo->save($current);
// find next level
$next = $this->approvalRepo->getNext($requestId, $current->getLevel());
if ($next) {
$next->setStatus('pending');
$this->approvalRepo->save($next);
$this->notifyApprover($next);
} else {
// all steps done
$this->requestRepo->changeStatus($requestId, 'approved');
$this->notifyRequestor($requestId);
}
}
}
Make sure permissions and ACL checks are enforced before approval actions.
Example approval table row
| approval_id | request_id | approver_id | level | status | comments | acted_at |
|-------------|------------|-------------|-------|----------|---------------|--------------------|
| 1 | 42 | 56 | 1 | approved | Ok for order | 2024-04-20 09:12:00 |
| 2 | 42 | 78 | 2 | pending | | |
Notifications: email alert system
Notifications should be event-driven, but fail-safe. Use observers and a queued email sender when possible.
Event strategy
- Events: rfq_request.created, rfq_request.updated, rfq_request.approved, rfq_quote.proposed
- Observers/consumers: send email to admin, approver, or requestor
- Use TransportBuilder to build and send emails, and mark send attempts in rfq_history
// Observer example: Observer/NotifyApprover.php
public function execute(\Magento\Framework\Event\Observer $observer)
{
$request = $observer->getData('request');
$nextApprover = $this->approvalRepo->getNext($request->getId());
if ($nextApprover) {
$this->transportBuilder
->setTemplateIdentifier('rfq_approval_request_template')
->setTemplateOptions(['area' => 'frontend', 'store' => $this->storeManager->getStore()->getId()])
->setTemplateVars(['request' => $request, 'approver' => $nextApprover])
->setFrom('general')
->addTo($approverEmail)
->getTransport()
->sendMessage();
}
}
Define email templates in etc/email_templates.xml and make template identifiers readable (rfq_approval_request_template).
Admin dashboard: unified interface and reporting
Admins need a single place to monitor and act: a dashboard with key widgets and a detailed grid for RFQs.
Admin menu and ACL
// etc/adminhtml/menu.xml
<menu>
<add id="Magefine_Rfq::root" title="RFQ" module="Magefine_Rfq" sortOrder="100" />
<add id="Magefine_Rfq::requests" title="Requests" module="Magefine_Rfq" parent="Magefine_Rfq::root" action="rfq/request/index" />
</menu>
// etc/acl.xml
<acl>
<resources>
<resource id="Magento_Backend::admin">
<resource id="Magefine_Rfq::root" title="RFQ" sortOrder="100">
<resource id="Magefine_Rfq::requests" title="Manage Requests" />
</resource>
</resource>
</resources>
</acl>
Grid and UI component example
Use the UI Component grid for basic CRUD and server-side filters. Add columns for status, customer, total, created_at, actions.
// view/adminhtml/ui_component/rfq_request_listing.xml
<listing xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Ui:etc/ui_configuration.xsd">
<columns name="rfq_columns">
<column name="request_id"/>
<column name="customer_email"/>
<column name="status"/>
<column name="total"/>
<actionsColumn name="actions">
<settings><indexField>request_id</indexField></settings>
</actionsColumn>
</columns>
</listing>
Dashboard widgets
- Open RFQs count (by status)
- Average response time (time between request creation and first quote)
- Conversion rate (RFQ → Order)
- Top products requested
For charts, you can build a small controller that returns simple JSON and feed a JS charting library (Chart.js) in the admin page.
API and storefront forms
Expose endpoints for creating RFQs from the storefront (form) and for B2B ERP integrations. Keep the API secure (OAuth or token-based) and validate configurator_json on create.
// example controller: Controller/Index/Create.php
public function execute()
{
$post = $this->getRequest()->getPostValue();
// sanitize and validate
$request = $this->requestFactory->create();
$request->setData([
'customer_id' => $this->customerSession->getCustomerId(),
'status' => 'new',
'created_at' => date('Y-m-d H:i:s')
]);
$this->requestRepository->save($request);
foreach ($post['items'] as $item) {
$rfqItem = $this->itemFactory->create();
$rfqItem->setData([
'request_id' => $request->getId(),
'product_id' => $item['product_id'],
'sku' => $item['sku'],
'qty' => (int)$item['qty'],
'configurator_json' => json_encode($item['configurator'])
]);
$this->itemRepository->save($rfqItem);
}
// dispatch event for notifications
$this->_eventManager->dispatch('rfq_request.created', ['request' => $request]);
$this->messageManager->addSuccessMessage(__('RFQ submitted.'));
$this->_redirect('rfq/thankyou');
}
Automated notifications: reliability patterns
Some failed emails are inevitable. To be reliable:
- Use Magento's message queue (RabbitMQ) for email sending when the store receives high traffic
- Fallback: log failed attempts and retry via cron
- Store send attempts in rfq_history and flag messages needing retries
Security and permissions
RFQ operations touch sensitive pricing and company info. Consider:
- ACL for admin actions (approve, edit, delete)
- Per-company isolation if you host multiple B2B accounts
- Data retention and GDPR: allow requestors to remove attachments
- Sanitize configurator JSON (no direct HTML) and validate uploaded files
Testing and QA tips
- Automated unit tests for price calculator logic
- Integration tests for request creation → approval flow → quote creation
- Manual checks for email templates and localization
- Performance testing when many RFQs/items are created at once
Deployment and upgrades
Because you store RFQs in custom tables, include upgrade_schema patches or declarative schema changes with careful data migration. Keep these practices:
- Use declarative db_schema.xml to evolve tables
- Keep backward compatibility for configurator_json fields when altering format
- Provide export/import if you change the JSON structure (migration script)
Putting it together — an example flow
Here’s a concrete example of how the system behaves:
- Customer submits RFQ with 3 items (configurable product + 2 simples). The frontend controller stores rfq_request and rfq_item rows, saves configurator JSON.
- Event rfq_request.created fires. Observer queues an email to notify the first approver (procurement).
- Procurement logs into admin, opens the RFQ. The item configurator JSON is used to present the exact product. Procurement adjusts quantities and calls the priceCalculator to propose prices.
- Procurement submits quote. rfq_quote row is created and rfq_request status changes to awaiting_approval_level_2. ApprovalService creates approval row for next level and notifies next approver (finance).
- Finance approves. ApprovalService advances to management or completes the approval. When all levels are approved, the requestor receives an email and the RFQ can be converted to an order in Magento (optionally creating a quote object or sales order programmatically).
Sample code: sending an email with TransportBuilder
// simplified service
public function sendApprovalRequestEmail($approverEmail, $request)
{
$this->transportBuilder
->setTemplateIdentifier('rfq_approval_request_template')
->setTemplateOptions(['area' => 'frontend', 'store' => $this->storeManager->getStore()->getId()])
->setTemplateVars(['request' => $request])
->setFrom('general')
->addTo($approverEmail);
try {
$this->transportBuilder->getTransport()->sendMessage();
$this->historyRepo->log($request->getId(), 'email_sent', ['to' => $approverEmail]);
} catch (\Exception $e) {
$this->historyRepo->log($request->getId(), 'email_failed', ['error' => $e->getMessage()]);
}
}
Converting an approved RFQ to an order
After final approval, provide a 'Convert to Order' action. Use Magento's Quote and Order APIs and rehydrate items from rfq_item config.
// pseudo-code for convert
$cartQuote = $this->cartFactory->create();
foreach ($rfqItems as $item) {
// if configurable - find the simple product id from super_attributes
$cartQuote->addProduct($product, ['qty' => $item->getQty(), 'super_attribute' => $item->getConfiguratorJson()['super_attributes']]);
}
$cartQuote->collectTotals()->save();
$this->orderService->submitQuote($cartQuote); // create order
Analytics and KPIs for RFQ performance
Some useful KPIs to show on the admin dashboard:
- Average time to first quote (hrs)
- Average time to final approval
- Conversion rate (RFQ → Order)
- Quote acceptance ratio per sales rep
- Top requested SKUs and average requested price vs final price
Example queries for reporting
-- open RFQs by status
SELECT status, COUNT(*) as cnt FROM rfq_request GROUP BY status;
-- average response time (assuming created_at and first_quote_at)
SELECT AVG(TIMESTAMPDIFF(SECOND, created_at, first_quote_at)) / 3600 as avg_hours FROM rfq_request WHERE first_quote_at IS NOT NULL;
Extensions and integrations
Common integrations for RFQ modules:
- ERP systems — sync approved quotes and orders via API
- Pricing engines or CPQ systems — for complex product configurators and BOMs
- Single Sign-On (SSO) for company approvers
Performance considerations
If your store handles thousands of RFQs:
- Index counts and commonly queried fields
- Use pagination and server-side filtering in the admin grids
- Consider moving heavy analytics to a read replica or BI pipeline
- Use queues for asynchronous processing (emails, notifications)
Summary and next steps
Building a custom RFQ module for complex B2B products in Magento 2 is a matter of defining the right data model, a reliable approval workflow, a flexible price calculator, and a clean admin UX. Here’s a checklist to continue:
- Create the declarative schema with rfq_request, rfq_item, rfq_quote, rfq_approval and rfq_history
- Implement models, resource models and repositories for each entity
- Capture product configuration as JSON and ensure rehydration in admin
- Build a modular PriceCalculator to handle conditional pricing
- Implement ApprovalService and approval rows for multi-level approvals
- Hook events and observers to send reliable notifications (use queues)
- Create admin UI: listing, view details, approve actions and dashboard widgets
- Add analytics queries and export features for reporting
- Write unit and integration tests around pricing and approval flows
If you want, I can:
- Generate a starter module skeleton with the files above
- Provide full repository/service implementations for Request + Approval
- Sketch the email templates and UI component XML for the admin pages
Tell me which part you want first and I’ll hand you the exact files ready to drop into a module. If you’re building this for magefine.com or want it merchant-ready (clean ACL, i18n, packaging), I can tailor the skeleton for distribution and hosting best practices.