Product Types Development
Deep dive into Magento product type architecture, custom product type creation, price calculation, stock management, and admin/frontend rendering.
Product Types Development
Overview
Magento's product type architecture provides a flexible framework for representing different kinds of products (simple, configurable, bundle, grouped, downloadable, virtual). Understanding this system is essential for creating custom product types that integrate seamlessly with catalog, cart, checkout, and order workflows.
What you'll learn: - Product type architecture and the type registry system - Creating custom product types with full lifecycle support - Price calculation strategies and price models - Stock management and salability checks - Admin UI integration with product forms and data providers - Frontend rendering and add-to-cart customization - Cart item representation and order item handling
Prerequisites: - Magento 2.4.7+ (Adobe Commerce or Open Source) - Advanced understanding of Magento module structure - Experience with dependency injection and service contracts - Knowledge of UI components and catalog architecture - Familiarity with EAV attributes and product data management
Product Type Architecture
Core Components
Magento's product type system consists of:
- Type Model:
Magento\Catalog\Model\Product\Type\AbstractType- Core logic for product type - Price Model:
Magento\Catalog\Model\Product\Type\Price- Price calculation - Type Registry:
Magento\Catalog\Model\Product\Type- Manages available types - Type Configuration:
catalog_attributes.xmlandproduct_types.xml - UI Components: Admin form configuration and data providers
- Frontend Blocks: Product detail and list page rendering
Product Type Lifecycle
[Product Creation] → [Attribute Assignment] → [Data Validation]
↓
[Price Calculation] → [Salability Check] → [Add to Cart]
↓
[Cart Item Representation] → [Checkout] → [Order Creation]
↓
[Order Item Processing] → [Fulfillment] → [Invoicing]
Type Registration Flow
product_types.xml → Type Registry → Factory → Type Model Instance
↓
Price Model Assignment → Option Processing → Validation Rules
Creating a Custom Product Type
Use Case: Rental Product Type
We'll create a "rental" product type that supports time-based pricing and availability management.
Step 1: Declare Product Type
File: Vendor/RentalProduct/etc/product_types.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Catalog:etc/product_types.xsd">
<type name="rental" label="Rental Product" modelInstance="Vendor\RentalProduct\Model\Product\Type\Rental"
indexPriority="60" sortOrder="80" isQty="true">
<!-- Composable type configuration -->
<priceModel instance="Vendor\RentalProduct\Model\Product\Type\Rental\Price"/>
<!-- Allowed selection types -->
<allowedSelectionTypes>
<type name="simple"/>
</allowedSelectionTypes>
<!-- Stock indexer configuration -->
<stockIndexerModel>Magento\CatalogInventory\Model\Indexer\Stock\Processor</stockIndexerModel>
<!-- Custom attributes used by this type -->
<customAttributes>
<attribute name="rental_duration" retrieve="true"/>
<attribute name="rental_deposit" retrieve="true"/>
<attribute name="rental_late_fee" retrieve="true"/>
</customAttributes>
</type>
</config>
Step 2: Configure Attributes for Type
File: Vendor/RentalProduct/etc/catalog_attributes.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Catalog:etc/catalog_attributes.xsd">
<!-- Attributes applicable to rental products -->
<group name="rental">
<attribute name="name"/>
<attribute name="sku"/>
<attribute name="price"/>
<attribute name="weight"/>
<attribute name="status"/>
<attribute name="visibility"/>
<attribute name="description"/>
<attribute name="short_description"/>
<attribute name="rental_duration"/>
<attribute name="rental_deposit"/>
<attribute name="rental_late_fee"/>
<attribute name="rental_max_days"/>
<attribute name="rental_min_days"/>
</group>
</config>
Step 3: Create Type Model
File: Vendor/RentalProduct/Model/Product/Type/Rental.php
<?php
declare(strict_types=1);
namespace Vendor\RentalProduct\Model\Product\Type;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Catalog\Model\Product;
use Magento\Catalog\Model\Product\Type\AbstractType;
use Magento\Framework\DataObject;
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\Serialize\Serializer\Json;
class Rental extends AbstractType
{
public const TYPE_ID = 'rental';
/**
* Delete data specific for rental product type
*/
public function deleteTypeSpecificData(Product $product): void
{
// Clean up rental-specific tables if any
}
/**
* Check if product is available for sale
*
* @param Product $product
* @return bool
*/
public function isSalable($product): bool
{
$salable = parent::isSalable($product);
if (!$salable) {
return false;
}
// Check rental-specific availability
$rentalAvailability = $product->getData('rental_availability');
if ($rentalAvailability === null) {
return true;
}
return (bool)$rentalAvailability;
}
/**
* Check if product has required options
*
* @param Product $product
* @return bool
*/
public function hasRequiredOptions($product): bool
{
// Rental products require start/end date selection
return true;
}
/**
* Prepare product for cart
*
* @param DataObject $buyRequest
* @param Product $product
* @param string $processMode
* @return array|string
* @throws LocalizedException
*/
protected function _prepareProduct(DataObject $buyRequest, $product, $processMode)
{
$result = parent::_prepareProduct($buyRequest, $product, $processMode);
if (is_string($result)) {
return $result;
}
// Validate rental dates
$rentalStart = $buyRequest->getData('rental_start_date');
$rentalEnd = $buyRequest->getData('rental_end_date');
if (!$rentalStart || !$rentalEnd) {
return __('Please select rental start and end dates.')->render();
}
// Validate date range
$startDate = new \DateTime($rentalStart);
$endDate = new \DateTime($rentalEnd);
$days = $endDate->diff($startDate)->days;
if ($days < 1) {
return __('Rental period must be at least 1 day.')->render();
}
$minDays = (int)$product->getData('rental_min_days') ?: 1;
$maxDays = (int)$product->getData('rental_max_days') ?: 365;
if ($days < $minDays) {
return __('Minimum rental period is %1 days.', $minDays)->render();
}
if ($days > $maxDays) {
return __('Maximum rental period is %1 days.', $maxDays)->render();
}
// Check availability for date range
if (!$this->isAvailableForDateRange($product, $startDate, $endDate)) {
return __('Product is not available for selected dates.')->render();
}
// Add rental info to product
$product->addCustomOption('rental_start_date', $rentalStart);
$product->addCustomOption('rental_end_date', $rentalEnd);
$product->addCustomOption('rental_days', (string)$days);
return $result;
}
/**
* Check product availability for date range
*/
private function isAvailableForDateRange(
Product $product,
\DateTime $startDate,
\DateTime $endDate
): bool {
// Implement availability calendar check
// Query rental_availability table or calendar service
return true; // Simplified for example
}
/**
* Process product configuration
*
* @param Product $product
* @return Product
*/
public function processConfiguration($product): Product
{
// Process rental-specific configuration
return $product;
}
/**
* Get product options
*
* @param Product $product
* @return array
*/
public function getOrderOptions($product): array
{
$options = parent::getOrderOptions($product);
$rentalOptions = [
'rental_start_date' => $product->getCustomOption('rental_start_date'),
'rental_end_date' => $product->getCustomOption('rental_end_date'),
'rental_days' => $product->getCustomOption('rental_days'),
];
$options['rental_options'] = array_filter($rentalOptions);
return $options;
}
/**
* Check if product can be configured
*
* @param Product $product
* @return bool
*/
public function canConfigure($product): bool
{
return true;
}
/**
* Prepare selected options for rental product
*
* @param Product $product
* @param DataObject $buyRequest
* @return array
*/
public function processBuyRequest($product, $buyRequest): array
{
$rentalOptions = [];
if ($buyRequest->hasData('rental_start_date')) {
$rentalOptions['rental_start_date'] = $buyRequest->getData('rental_start_date');
}
if ($buyRequest->hasData('rental_end_date')) {
$rentalOptions['rental_end_date'] = $buyRequest->getData('rental_end_date');
}
return $rentalOptions;
}
/**
* Check if product is a virtual product
*/
public function isVirtual($product): bool
{
return false; // Rentals are physical products
}
/**
* Get child products
*/
public function getChildrenIds($productId, $required = true): array
{
return [[]]; // No children for rental products
}
/**
* Retrieve products divided into groups required to purchase
*/
public function getProductsToPurchaseByReqGroups($product): array
{
return [[$product]];
}
}
Price Calculation
Custom Price Model
File: Vendor/RentalProduct/Model/Product/Type/Rental/Price.php
<?php
declare(strict_types=1);
namespace Vendor\RentalProduct\Model\Product\Type\Rental;
use Magento\Catalog\Model\Product;
use Magento\Catalog\Model\Product\Type\Price as BasePrice;
use Magento\Framework\Pricing\PriceCurrencyInterface;
class Price extends BasePrice
{
public function __construct(
\Magento\CatalogRule\Model\ResourceModel\RuleFactory $ruleFactory,
\Magento\Store\Model\StoreManagerInterface $storeManager,
\Magento\Framework\Stdlib\DateTime\TimezoneInterface $localeDate,
\Magento\Customer\Model\Session $customerSession,
\Magento\Framework\Event\ManagerInterface $eventManager,
PriceCurrencyInterface $priceCurrency,
\Magento\Customer\Api\GroupManagementInterface $groupManagement,
\Magento\Catalog\Api\Data\ProductTierPriceInterfaceFactory $tierPriceFactory,
\Magento\Framework\App\Config\ScopeConfigInterface $config,
private readonly \Psr\Log\LoggerInterface $logger
) {
parent::__construct(
$ruleFactory,
$storeManager,
$localeDate,
$customerSession,
$eventManager,
$priceCurrency,
$groupManagement,
$tierPriceFactory,
$config
);
}
/**
* Get product final price
*
* @param float|null $qty
* @param Product $product
* @return float
*/
public function getFinalPrice($qty, $product): float
{
if ($qty === null && $product->getCalculatedFinalPrice() !== null) {
return (float)$product->getCalculatedFinalPrice();
}
$basePrice = $this->getBasePrice($product, $qty);
$finalPrice = $this->calculateRentalPrice($basePrice, $product);
$product->setFinalPrice($finalPrice);
$this->_eventManager->dispatch('catalog_product_get_final_price', ['product' => $product, 'qty' => $qty]);
$finalPrice = (float)$product->getData('final_price');
$finalPrice = $this->_applyOptionsPrice($product, $qty, $finalPrice);
$finalPrice = max(0, $finalPrice);
$product->setFinalPrice($finalPrice);
return $finalPrice;
}
/**
* Calculate rental price based on duration and deposit
*/
private function calculateRentalPrice(float $basePrice, Product $product): float
{
$rentalDays = $this->getRentalDays($product);
if ($rentalDays === null) {
// No rental period selected yet, return daily rate
return $basePrice;
}
// Calculate rental fee
$rentalFee = $basePrice * $rentalDays;
// Add deposit if configured
$deposit = (float)$product->getData('rental_deposit');
if ($deposit > 0) {
$rentalFee += $deposit;
}
// Apply bulk discounts for longer rentals
$discount = $this->calculateBulkDiscount($rentalDays);
if ($discount > 0) {
$rentalFee = $rentalFee * (1 - $discount);
}
return $rentalFee;
}
/**
* Get rental days from product options
*/
private function getRentalDays(Product $product): ?int
{
$customOption = $product->getCustomOption('rental_days');
if ($customOption) {
return (int)$customOption->getValue();
}
// Check if rental days in additional options
$additionalOptions = $product->getCustomOption('additional_options');
if ($additionalOptions) {
$options = $this->serializer->unserialize($additionalOptions->getValue());
if (isset($options['rental_days'])) {
return (int)$options['rental_days'];
}
}
return null;
}
/**
* Calculate bulk discount based on rental duration
*/
private function calculateBulkDiscount(int $days): float
{
// Example: 10% off for 7+ days, 20% off for 30+ days
if ($days >= 30) {
return 0.20;
} elseif ($days >= 7) {
return 0.10;
}
return 0.0;
}
/**
* Get product tier price by qty
*/
public function getTierPrice($qty, $product): float
{
// Rental products don't use traditional tier pricing
return $this->getBasePrice($product, $qty);
}
/**
* Calculate deposit amount
*/
public function getDepositAmount(Product $product): float
{
return (float)$product->getData('rental_deposit');
}
/**
* Calculate late fee per day
*/
public function getLateFeePerDay(Product $product): float
{
return (float)$product->getData('rental_late_fee');
}
}
Stock Management
Custom Stock Status
File: Vendor/RentalProduct/Model/ResourceModel/Product/Indexer/Price/Rental.php
<?php
declare(strict_types=1);
namespace Vendor\RentalProduct\Model\ResourceModel\Product\Indexer\Price;
use Magento\Catalog\Model\ResourceModel\Product\Indexer\Price\DefaultPrice;
class Rental extends DefaultPrice
{
/**
* Prepare rental product prices
*/
protected function _prepareDefaultFinalPriceTable(): self
{
$this->_prepareWebsiteDateTable();
$connection = $this->getConnection();
$select = $connection->select()
->from(['e' => $this->getTable('catalog_product_entity')], ['entity_id'])
->join(
['cg' => $this->getTable('customer_group')],
'',
['customer_group_id']
)
->join(
['cw' => $this->getTable('store_website')],
'',
['website_id']
)
->join(
['cwd' => $this->_getWebsiteDateTable()],
'cw.website_id = cwd.website_id',
[]
)
->join(
['csg' => $this->getTable('store_group')],
'csg.website_id = cw.website_id AND cw.default_group_id = csg.group_id',
[]
)
->join(
['cs' => $this->getTable('store')],
'csg.default_store_id = cs.store_id AND cs.store_id != 0',
[]
)
->where('e.type_id = ?', 'rental');
// Add price attribute
$this->_addAttributeToSelect($select, 'price', 'e.entity_id', 'cs.store_id', 0);
// Add rental deposit
$this->_addAttributeToSelect($select, 'rental_deposit', 'e.entity_id', 'cs.store_id', 0, 'rental_deposit');
$select->columns([
'orig_price' => new \Zend_Db_Expr('price.value'),
'price' => new \Zend_Db_Expr('price.value'),
'min_price' => new \Zend_Db_Expr('price.value'),
'max_price' => new \Zend_Db_Expr('price.value + COALESCE(rental_deposit.value, 0)'),
'tier_price' => new \Zend_Db_Expr('NULL'),
]);
$connection->query(
$connection->insertFromSelect(
$select,
$this->_getDefaultFinalPriceTable(),
[],
\Magento\Framework\DB\Adapter\AdapterInterface::INSERT_ON_DUPLICATE
)
);
return $this;
}
}
Salability Check
File: Vendor/RentalProduct/Plugin/InventorySales/IsSalableExtend.php
<?php
declare(strict_types=1);
namespace Vendor\RentalProduct\Plugin\InventorySales;
use Magento\InventorySalesApi\Api\IsProductSalableInterface;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Vendor\RentalProduct\Model\RentalAvailabilityChecker;
class IsSalableExtend
{
public function __construct(
private readonly ProductRepositoryInterface $productRepository,
private readonly RentalAvailabilityChecker $availabilityChecker
) {
}
/**
* Add rental availability check
*/
public function afterExecute(
IsProductSalableInterface $subject,
bool $result,
string $sku,
int $stockId
): bool {
if (!$result) {
return false;
}
try {
$product = $this->productRepository->get($sku);
if ($product->getTypeId() !== 'rental') {
return $result;
}
// Check if rental has available inventory for any future dates
return $this->availabilityChecker->hasAvailability($product);
} catch (\Exception $e) {
return $result;
}
}
}
Admin UI Configuration
Product Form UI Component
File: Vendor/RentalProduct/view/adminhtml/ui_component/product_form.xml
<?xml version="1.0"?>
<form xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Ui:etc/ui_configuration.xsd">
<!-- Rental Information Section -->
<fieldset name="rental-information" sortOrder="20">
<settings>
<label translate="true">Rental Information</label>
<collapsible>true</collapsible>
<opened>true</opened>
<visible>true</visible>
</settings>
<field name="rental_duration" formElement="select" sortOrder="10">
<settings>
<validation>
<rule name="required-entry" xsi:type="boolean">true</rule>
</validation>
<dataType>text</dataType>
<label translate="true">Rental Duration Type</label>
<dataScope>rental_duration</dataScope>
</settings>
<formElements>
<select>
<settings>
<options class="Vendor\RentalProduct\Model\Config\Source\DurationType"/>
</settings>
</select>
</formElements>
</field>
<field name="rental_deposit" formElement="input" sortOrder="20">
<settings>
<validation>
<rule name="validate-number" xsi:type="boolean">true</rule>
<rule name="validate-zero-or-greater" xsi:type="boolean">true</rule>
</validation>
<dataType>number</dataType>
<label translate="true">Security Deposit</label>
<dataScope>rental_deposit</dataScope>
</settings>
</field>
<field name="rental_late_fee" formElement="input" sortOrder="30">
<settings>
<validation>
<rule name="validate-number" xsi:type="boolean">true</rule>
<rule name="validate-zero-or-greater" xsi:type="boolean">true</rule>
</validation>
<dataType>number</dataType>
<label translate="true">Late Fee (per day)</label>
<dataScope>rental_late_fee</dataScope>
<tooltip>
<description translate="true">Fee charged per day when rental is returned late</description>
</tooltip>
</settings>
</field>
<field name="rental_min_days" formElement="input" sortOrder="40">
<settings>
<validation>
<rule name="validate-number" xsi:type="boolean">true</rule>
<rule name="validate-greater-than-zero" xsi:type="boolean">true</rule>
</validation>
<dataType>number</dataType>
<label translate="true">Minimum Rental Days</label>
<dataScope>rental_min_days</dataScope>
</settings>
</field>
<field name="rental_max_days" formElement="input" sortOrder="50">
<settings>
<validation>
<rule name="validate-number" xsi:type="boolean">true</rule>
<rule name="validate-greater-than-zero" xsi:type="boolean">true</rule>
</validation>
<dataType>number</dataType>
<label translate="true">Maximum Rental Days</label>
<dataScope>rental_max_days</dataScope>
</settings>
</field>
<!-- Rental Availability Calendar -->
<container name="rental_availability_container" sortOrder="60">
<htmlContent name="rental_availability_calendar">
<block class="Vendor\RentalProduct\Block\Adminhtml\Product\Edit\RentalCalendar"
name="rental.calendar"
template="Vendor_RentalProduct::product/edit/rental_calendar.phtml"/>
</htmlContent>
</container>
</fieldset>
</form>
Data Provider Modifier
File: Vendor/RentalProduct/Ui/DataProvider/Product/Form/Modifier/RentalData.php
<?php
declare(strict_types=1);
namespace Vendor\RentalProduct\Ui\DataProvider\Product\Form\Modifier;
use Magento\Catalog\Ui\DataProvider\Product\Form\Modifier\AbstractModifier;
use Magento\Catalog\Model\Locator\LocatorInterface;
class RentalData extends AbstractModifier
{
public function __construct(
private readonly LocatorInterface $locator
) {
}
/**
* Modify product data
*/
public function modifyData(array $data): array
{
$product = $this->locator->getProduct();
if ($product->getTypeId() !== 'rental') {
return $data;
}
$productId = $product->getId();
if (isset($data[$productId])) {
// Add rental-specific data
$data[$productId]['product']['rental_data'] = [
'deposit' => $product->getData('rental_deposit'),
'late_fee' => $product->getData('rental_late_fee'),
'min_days' => $product->getData('rental_min_days'),
'max_days' => $product->getData('rental_max_days'),
];
}
return $data;
}
/**
* Modify product form meta
*/
public function modifyMeta(array $meta): array
{
$product = $this->locator->getProduct();
if ($product->getTypeId() !== 'rental') {
return $meta;
}
// Show/hide fields based on rental type
return $meta;
}
}
Frontend Rendering
Product Detail Page
File: Vendor/RentalProduct/view/frontend/templates/product/view/type/rental.phtml
<?php
/**
* @var \Magento\Catalog\Block\Product\View $block
* @var \Magento\Framework\Escaper $escaper
*/
$product = $block->getProduct();
?>
<div class="rental-options" id="rental-options-<?= (int)$product->getId() ?>">
<div class="rental-dates">
<div class="field rental-start-date">
<label class="label" for="rental-start-date">
<span><?= $escaper->escapeHtml(__('Start Date')) ?></span>
</label>
<div class="control">
<input type="text"
id="rental-start-date"
name="rental_start_date"
class="input-text rental-datepicker"
data-validate='{"required":true}'
data-rental-min-days="<?= (int)$product->getData('rental_min_days') ?>"
data-rental-max-days="<?= (int)$product->getData('rental_max_days') ?>"/>
</div>
</div>
<div class="field rental-end-date">
<label class="label" for="rental-end-date">
<span><?= $escaper->escapeHtml(__('End Date')) ?></span>
</label>
<div class="control">
<input type="text"
id="rental-end-date"
name="rental_end_date"
class="input-text rental-datepicker"
data-validate='{"required":true}'/>
</div>
</div>
</div>
<div class="rental-pricing-breakdown">
<div class="rental-daily-rate">
<span class="label"><?= $escaper->escapeHtml(__('Daily Rate:')) ?></span>
<span class="value" data-price-type="basePrice"><?= $block->formatCurrency($product->getFinalPrice()) ?></span>
</div>
<div class="rental-duration" style="display:none;">
<span class="label"><?= $escaper->escapeHtml(__('Rental Duration:')) ?></span>
<span class="value" id="rental-duration-display">0 days</span>
</div>
<div class="rental-subtotal" style="display:none;">
<span class="label"><?= $escaper->escapeHtml(__('Rental Subtotal:')) ?></span>
<span class="value" id="rental-subtotal-display"><?= $block->formatCurrency(0) ?></span>
</div>
<?php if ($product->getData('rental_deposit')): ?>
<div class="rental-deposit">
<span class="label"><?= $escaper->escapeHtml(__('Security Deposit:')) ?></span>
<span class="value"><?= $block->formatCurrency($product->getData('rental_deposit')) ?></span>
</div>
<?php endif; ?>
<div class="rental-total" style="display:none;">
<span class="label"><?= $escaper->escapeHtml(__('Total Due Today:')) ?></span>
<span class="value price" id="rental-total-display"><?= $block->formatCurrency(0) ?></span>
</div>
</div>
</div>
<script type="text/x-magento-init">
{
"#rental-options-<?= (int)$product->getId() ?>": {
"Vendor_RentalProduct/js/rental-calculator": {
"productId": <?= (int)$product->getId() ?>,
"dailyRate": <?= (float)$product->getFinalPrice() ?>,
"deposit": <?= (float)$product->getData('rental_deposit') ?>,
"minDays": <?= (int)$product->getData('rental_min_days') ?>,
"maxDays": <?= (int)$product->getData('rental_max_days') ?>,
"priceFormat": <?= $block->getPriceFormatJson() ?>
}
}
}
</script>
Rental Calculator Widget
File: Vendor/RentalProduct/view/frontend/web/js/rental-calculator.js
define([
'jquery',
'Magento_Catalog/js/price-utils',
'mage/calendar',
'mage/translate'
], function ($, priceUtils, calendar, $t) {
'use strict';
$.widget('vendor.rentalCalculator', {
options: {
productId: null,
dailyRate: 0,
deposit: 0,
minDays: 1,
maxDays: 365,
priceFormat: {}
},
_create: function () {
this._initDatepickers();
this._bindEvents();
},
_initDatepickers: function () {
var self = this;
var minDate = new Date();
minDate.setDate(minDate.getDate() + 1);
this.element.find('.rental-datepicker').calendar({
minDate: minDate,
dateFormat: 'mm/dd/yy',
showButtonPanel: true,
beforeShowDay: function (date) {
return self._checkAvailability(date);
}
});
},
_bindEvents: function () {
var self = this;
this.element.find('#rental-start-date, #rental-end-date').on('change', function () {
self._calculatePrice();
});
},
_checkAvailability: function (date) {
// AJAX call to check availability
// Returns [true, ''] for available, [false, '', 'disabled'] for unavailable
return [true, ''];
},
_calculatePrice: function () {
var startDate = this.element.find('#rental-start-date').val();
var endDate = this.element.find('#rental-end-date').val();
if (!startDate || !endDate) {
return;
}
var start = new Date(startDate);
var end = new Date(endDate);
var days = Math.ceil((end - start) / (1000 * 60 * 60 * 24));
if (days < this.options.minDays) {
this._showError($t('Minimum rental period is %1 days').replace('%1', this.options.minDays));
return;
}
if (days > this.options.maxDays) {
this._showError($t('Maximum rental period is %1 days').replace('%1', this.options.maxDays));
return;
}
this._hideError();
// Calculate pricing
var rentalSubtotal = this.options.dailyRate * days;
var discount = this._calculateDiscount(days);
rentalSubtotal = rentalSubtotal * (1 - discount);
var total = rentalSubtotal + this.options.deposit;
// Update display
this.element.find('#rental-duration-display').text(days + ' ' + $t('days'));
this.element.find('#rental-subtotal-display').text(
priceUtils.formatPrice(rentalSubtotal, this.options.priceFormat)
);
this.element.find('#rental-total-display').text(
priceUtils.formatPrice(total, this.options.priceFormat)
);
this.element.find('.rental-duration, .rental-subtotal, .rental-total').show();
},
_calculateDiscount: function (days) {
if (days >= 30) {
return 0.20;
} else if (days >= 7) {
return 0.10;
}
return 0;
},
_showError: function (message) {
// Show validation error
console.error(message);
},
_hideError: function () {
// Hide validation error
}
});
return $.vendor.rentalCalculator;
});
Cart and Checkout Integration
Quote Item Processor
File: Vendor/RentalProduct/Model/Quote/Item/Processor.php
<?php
declare(strict_types=1);
namespace Vendor\RentalProduct\Model\Quote\Item;
use Magento\Quote\Model\Quote\Item;
use Magento\Framework\Serialize\Serializer\Json;
class Processor
{
public function __construct(
private readonly Json $serializer
) {
}
/**
* Process rental options for cart item
*/
public function processItem(Item $item): void
{
$product = $item->getProduct();
if ($product->getTypeId() !== 'rental') {
return;
}
// Get rental options
$options = [];
if ($rentalStart = $product->getCustomOption('rental_start_date')) {
$options['rental_start_date'] = $rentalStart->getValue();
}
if ($rentalEnd = $product->getCustomOption('rental_end_date')) {
$options['rental_end_date'] = $rentalEnd->getValue();
}
if ($rentalDays = $product->getCustomOption('rental_days')) {
$options['rental_days'] = $rentalDays->getValue();
}
// Store in additional options for display
if (!empty($options)) {
$additionalOptions = [
[
'label' => __('Rental Period'),
'value' => sprintf(
'%s to %s (%d days)',
$options['rental_start_date'],
$options['rental_end_date'],
$options['rental_days']
)
]
];
$item->addOption([
'code' => 'additional_options',
'value' => $this->serializer->serialize($additionalOptions)
]);
}
}
}
Testing
Unit Test Example
File: Vendor/RentalProduct/Test/Unit/Model/Product/Type/RentalTest.php
<?php
declare(strict_types=1);
namespace Vendor\RentalProduct\Test\Unit\Model\Product\Type;
use PHPUnit\Framework\TestCase;
use Vendor\RentalProduct\Model\Product\Type\Rental;
use Magento\Catalog\Model\Product;
use Magento\Framework\DataObject;
class RentalTest extends TestCase
{
private Rental $rentalType;
protected function setUp(): void
{
$this->rentalType = $this->createPartialMock(Rental::class, []);
}
public function testHasRequiredOptions(): void
{
$product = $this->createMock(Product::class);
$this->assertTrue($this->rentalType->hasRequiredOptions($product));
}
public function testPrepareProductWithMissingDates(): void
{
$product = $this->createMock(Product::class);
$buyRequest = new DataObject([]);
$result = $this->rentalType->_prepareProduct($buyRequest, $product, 'full');
$this->assertIsString($result);
$this->assertStringContainsString('select rental start and end dates', $result);
}
}
Summary
Key Takeaways: - Product types extend AbstractType and require price model, type configuration - Custom attributes must be declared in catalog_attributes.xml - Price calculation happens in dedicated Price model with support for dynamic pricing - Stock/salability checks integrate with MSI via plugins - Admin UI uses UI components with data provider modifiers - Frontend requires custom templates and JavaScript for user interaction - Cart integration uses custom options and additional_options for display - Order items preserve rental configuration through product options
Assumptions
- Magento Version: 2.4.7+ (Adobe Commerce)
- PHP Version: 8.2+
- MSI: Multi-Source Inventory enabled
- Database: Custom tables for rental availability calendar
- Frontend: Luma or custom theme with jQuery and UI widgets
Why This Approach
- Type System: Leverages Magento's native product type architecture
- Price Flexibility: Separate price model allows complex rental pricing logic
- Admin Integration: UI components provide native admin experience
- Cart Compatibility: Custom options ensure rental info flows through order workflow
- Extensibility: Plugins and events allow third-party customization
Security Impact
- Input Validation: Date inputs validated on server side
- Price Manipulation: Price recalculated server-side, not trusted from client
- Availability Checks: Authorization required for admin availability management
- PII: Rental dates may be considered PII in some jurisdictions
Performance Impact
- FPC: Product pages with rental calendar bypass FPC (use AJAX for calendar)
- Price Indexing: Custom price indexer adds overhead; optimize for rental products
- Availability Queries: Date range checks may be slow; use indexed availability table
- Cart: Minimal impact; rental options stored as serialized data
Backward Compatibility
- Type Registration: New product type doesn't affect existing types
- Database Schema: Attributes added via setup scripts, no core modifications
- API: Custom type excluded from API unless explicitly added
- Upgrade Path: Module upgrades preserve rental product data
Tests to Add
Unit Tests:
testRentalTypeIsSalable()
testPriceCalculationWithDiscount()
testDateRangeValidation()
Integration Tests:
testAddRentalProductToCart()
testRentalPriceIndexing()
testOrderCreationWithRentalProduct()
MFTF:
<test name="AdminCreateRentalProductTest">
<test name="StorefrontAddRentalToCartTest">
Docs to Update
- README.md: Installation, rental type usage, configuration
- docs/PRODUCT_TYPE.md: Architecture diagram, extension points
- docs/PRICING.md: Price calculation formulas, discount rules
- Admin User Guide: Screenshots of rental product creation workflow
Related Documentation
Related Guides
- EAV System Architecture: Understanding Entity-Attribute-Value in Magento 2
- Declarative Schema & Data Patches: Modern Database Management in Magento 2
- Import/Export System Deep Dive