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 comment build a custom RFQ module for complex B2B products in Magento 2 — architecture, DB entities, multi-level approval flux de travails, advanced product configuration handling, automated e-mail notifications, and an admin tableau de bord 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 tarification 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 tarification and custom prix calculators
  • Automated notifications and rapporting

A custom module vous donne the control to handle these. Below we’ll architect entities, show the critical XML/PHP fichiers, and give code exemples 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: e-mail 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, client_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_prix, requested_prix)
  • rfq_quote: store versions of quotes returned (quote_id, request_id, quote_data_json, total, valid_until, created_by)
  • rfq_approval: tracks approval étapes (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 fichiers:

// 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 bonnes pratiques: 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 prix 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 valeurs
  • any uploaded spec fichiers 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 résumé.

Conditional tarification and advanced prix calculator

B2B RFQ tarification is rarely a simple mulconseilly of base prix × qty. You’ll want a prix engine that can apply:

  • Attribute-based rules (e.g., material = stainless increases cost)
  • Quantity breaks and tier tarification
  • Customer-specific discounts/markup
  • One-time setup fees or conditional surcharges

Example prix 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 prix in the rfq_quote table; keep request level unmodified so you can compare vendor quotes to the original requested prix.

Implementing a multi-level approval flux de travail

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 commandeed 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);
        }
    }
}

Assurez-vous permissions and ACL checks are enforced before approval actions.

Example approval table ligne

| 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: e-mail alert system

Notifications devrait être event-driven, but fail-safe. Use observateurs and a queued e-mail sender when possible.

Event stratégie

  • Events: rfq_request.created, rfq_request.updated, rfq_request.approved, rfq_quote.proposed
  • Observers/consommateurs: send e-mail to admin, approver, or requestor
  • Use TransportBuilder to build and send e-mails, 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 e-mail templates in etc/e-mail_templates.xml and make template identifiers readable (rfq_approval_request_template).

Admin tableau de bord: unified interface and rapporting

Admins need a single place to monitor and act: a tableau de bord with clé 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 composant exemple

Use the UI Component grid for basic CRUD and server-side filtres. Add colonnes for status, client, 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 contrôleur that returns simple JSON and feed a JS charting library (Chart.js) in the admin page.

API and vitrine forms

Expose endpoints for creating RFQs from the vitrine (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 e-mails are inevitable. To be reliable:

  1. Use Magento's fichier de messages (RabbitMQ) for e-mail sending when the store receives high traffic
  2. Fallback: log failed attempts and retry via cron
  3. Store send attempts in rfq_history and flag messages needing retries

Security and permissions

RFQ operations touch sensitive tarification and company info. Consider:

  • ACL for admin actions (approve, edit, delete)
  • Per-company isolation if you host mulconseille B2B accounts
  • Data retention and GDPR: allow requestors to remove attachments
  • Sanitize configurator JSON (no direct HTML) and validate uploaded fichiers

Testing and QA conseils

  • Automated test unitaires for prix calculator logic
  • Integration tests for request creation → approval flow → quote creation
  • Manual checks for e-mail templates and localization
  • Performance test when many RFQs/items are created at once

Deployment and mise à jours

Parce que you store RFQs in custom tables, include mise à jour_schema correctifs 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 champs when altering format
  • Provide export/import if you change the JSON structure (migration script)

Putting it together — an exemple flow

Here’s a concrete exemple of how the system behaves:

  1. Customer submits RFQ with 3 items (configurable product + 2 simples). The frontend contrôleur stores rfq_request and rfq_item lignes, saves configurator JSON.
  2. Event rfq_request.created fires. Observer queues an e-mail to notify the first approver (procurement).
  3. Procurement logs into admin, opens the RFQ. The item configurator JSON est utilisé pour present the exact product. Procurement adjusts quantities and calls the prixCalculator to propose prixs.
  4. Procurement submits quote. rfq_quote ligne is created and rfq_request status changes to awaiting_approval_level_2. ApprovalService creates approval ligne for next level and notifies next approver (finance).
  5. Finance approves. ApprovalService advances to management or completes the approval. When all levels are approved, the requestor receives an e-mail and the RFQ peut être converted to an commande in Magento (optionally creating a quote objet or sales commande programmatically).

Sample code: sending an e-mail 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 commande

Après 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 tableau de bord:

  • 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 prix vs final prix

Example queries for rapporting

-- 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 commandes 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 champs
  • Use pagination and server-side filtreing in the grille d'administrations
  • Consider moving heavy analytics to a read replica or BI pipeline
  • Use queues for asynchronous processing (e-mails, notifications)

Résumé and next étapes

Building a custom RFQ module for complex B2B products in Magento 2 is a matter of defining the right modèle de données, a reliable approval flux de travail, a flexible prix calculator, and a clean admin UX. Here’s a checklist to continue:

  1. Create the declarative schema with rfq_request, rfq_item, rfq_quote, rfq_approval and rfq_history
  2. Implement models, resource models and repositories for each entity
  3. Capture product configuration as JSON and ensure rehydration in admin
  4. Build a modular PriceCalculator to handle conditional tarification
  5. Implement ApprovalService and approval lignes for multi-level approvals
  6. Hook events and observateurs to send reliable notifications (use queues)
  7. Create admin UI: listing, view details, approve actions and tableau de bord widgets
  8. Add analytics queries and export fonctionnalités for rapporting
  9. Write unit and test d'intégrations around tarification and approval flows

Si vous want, I can:

  • Generate a starter module skeleton with the fichiers above
  • Provide full repository/service implémentations for Request + Approval
  • Sketch the e-mail templates and UI composant XML for the admin pages

Tell me which part you want first and I’ll hand you the exact fichiers ready to drop into a module. If you’re building this for magefine.com or want it commerçant-ready (clean ACL, i18n, packaging), I can tailor the skeleton for distribution and hosting bonnes pratiques.