Prices
Price scope, special and tier prices, the catalog_product_price index, customer group pricing, and how tax display changes the number.
$product->getPriceInfo()->getPrice(FinalPrice::PRICE_CODE)->getValue(): floatA price is an attribute like any other. Three things make pricing its own subject: scope, the extra price attributes, and the index.
Scope
Catalog Price Scope is the config value catalog/price/scope, found under Stores > Configuration > Catalog > Catalog > Price. It is Global by default. On Global, a price entered for one website is stored as the global value and every website sees it. Set it to Website before expecting per-website prices.
The price attributes
price— the base price.special_price, withspecial_from_dateandspecial_to_date— a temporary lower price.tier_price— rows of customer group, quantity and value. Buy five and pay less, or let a wholesale group pay less at quantity one.cost— reporting only, never shown to a customer.
Catalog price rules are stored separately and applied on top of these.
The index
The storefront never computes any of this live. catalog_product_index_price holds a final price per product, website and customer group. A special price that began this morning does not appear until bin/magento indexer:reindex catalog_product_price runs, by hand or by cron.
Because the table is keyed by website and customer group it grows quickly. bin/magento indexer:set-dimensions-mode catalog_product_price website_and_customer_group splits it into one table per combination.
Tax changes the number
tax/display/type chooses excluding tax (1), including tax (2) or both (3), so the figure on the page is not always the figure in the index.
Scheduling a price change for a future date through Content Staging is Adobe Commerce only. In Open Source, use the special price dates.
Example
<?php
// app/code/Vendor/Pricing/Model/FinalPriceFormatter.php
declare(strict_types=1);
namespace Vendor\Pricing\Model;
use Magento\Catalog\Pricing\Price\FinalPrice;
use Magento\Catalog\Pricing\Price\RegularPrice;
use Magento\Framework\Pricing\PriceCurrencyInterface;
use Magento\Framework\Pricing\SaleableInterface;
class FinalPriceFormatter
{
public function __construct(
private readonly PriceCurrencyInterface $priceCurrency
) {
}
public function format(SaleableInterface $product): string
{
$priceInfo = $product->getPriceInfo();
$final = (float)$priceInfo->getPrice(FinalPrice::PRICE_CODE)->getValue();
$regular = (float)$priceInfo->getPrice(RegularPrice::PRICE_CODE)->getValue();
// false = no <span class="price"> container, just the number.
$formatted = $this->priceCurrency->format($final, false);
if ($final < $regular) {
$formatted .= ' (was ' . $this->priceCurrency->format($regular, false) . ')';
}
return $formatted;
}
}
// Called from a view model or a block. getPriceInfo() is declared on
// SaleableInterface, which Magento\Catalog\Model\Product implements; it is not
// on ProductInterface, so a product loaded from a web API payload has no
// price info and must go through the repository first.When to use it
- Explain to a merchant why the special price they entered this morning is not yet on the site.
- Set a per-website price after a second storefront launches and discover the scope setting is still Global.
- Seed quantity-break pricing for a wholesale customer group as part of a release.
- Match a custom price block to the store's excluding-tax or including-tax display setting.
More examples
Adding a tier price in a data patch
Use this to seed quantity discounts as part of a deployment rather than by hand in the admin. Note the interface: ProductTierPriceManagementInterface was deprecated in Catalog module version 102.0.0 in favour of ScopedProductTierPriceManagementInterface, which takes a ProductTierPriceInterface object instead of loose scalars. The tier price object is a data object, so it comes from its generated factory, not from the object manager.
<?php
// app/code/Vendor/Pricing/Setup/Patch/Data/AddWholesaleTier.php
declare(strict_types=1);
namespace Vendor\Pricing\Setup\Patch\Data;
use Magento\Catalog\Api\Data\ProductTierPriceInterfaceFactory;
use Magento\Catalog\Api\ScopedProductTierPriceManagementInterface;
use Magento\Customer\Model\Group;
use Magento\Framework\Setup\Patch\DataPatchInterface;
class AddWholesaleTier implements DataPatchInterface
{
public function __construct(
private readonly ProductTierPriceInterfaceFactory $tierPriceFactory,
private readonly ScopedProductTierPriceManagementInterface $tierPriceManagement
) {
}
public function apply(): self
{
$tierPrice = $this->tierPriceFactory->create();
$tierPrice->setCustomerGroupId(Group::NOT_LOGGED_IN_ID);
$tierPrice->setQty(10.0);
$tierPrice->setValue(29.99);
$this->tierPriceManagement->add('24-MB01', $tierPrice);
return $this;
}
public static function getDependencies(): array
{
return [];
}
public function getAliases(): array
{
return [];
}
}
// bin/magento setup:upgrade
// bin/magento indexer:reindex catalog_product_price
// bin/magento cache:flushConverting and formatting: shorthand and longhand
Reach for convertAndFormat() whenever the number you hold is in the base currency, which is the case for anything read out of the price index or an order's base_* column. Use format() alone when the value is already in the display currency, otherwise you convert twice. Avoid PriceCurrencyInterface::round(), which was deprecated in Magento Framework version 102.0.1.
<?php
// app/code/Vendor/Pricing/Model/CurrencyDisplay.php
declare(strict_types=1);
namespace Vendor\Pricing\Model;
use Magento\Framework\Pricing\PriceCurrencyInterface;
class CurrencyDisplay
{
public function __construct(
private readonly PriceCurrencyInterface $priceCurrency
) {
}
public function shorthand(float $baseAmount): string
{
return $this->priceCurrency->convertAndFormat($baseAmount, false);
}
public function longhand(float $baseAmount): string
{
// Exactly what convertAndFormat() does: convert from the base currency
// to the display currency, then format the result.
$converted = $this->priceCurrency->convert($baseAmount);
return $this->priceCurrency->format($converted, false);
}
}
// Output (base EUR, display EUR, rate 1.0):
// shorthand(29.99) === longhand(29.99) === '€29.99'Reading how prices should be displayed
Use this when a custom price block must match the rest of the storefront. The constant CONFIG_XML_PATH_PRICE_DISPLAY_TYPE resolves to tax/display/type, and the three values are DISPLAY_TYPE_EXCLUDING_TAX (1), DISPLAY_TYPE_INCLUDING_TAX (2) and DISPLAY_TYPE_BOTH (3). Reading the config path through a constant rather than a literal means a rename in a future release breaks the build instead of quietly changing behaviour. Config changes take effect after bin/magento cache:clean config.
<?php
// app/code/Vendor/Pricing/Model/TaxDisplay.php
declare(strict_types=1);
namespace Vendor\Pricing\Model;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Store\Model\ScopeInterface;
use Magento\Tax\Model\Config;
class TaxDisplay
{
public function __construct(
private readonly ScopeConfigInterface $scopeConfig
) {
}
public function showsBothPrices(?int $storeId = null): bool
{
$type = (int)$this->scopeConfig->getValue(
Config::CONFIG_XML_PATH_PRICE_DISPLAY_TYPE,
ScopeInterface::SCOPE_STORE,
$storeId
);
return $type === Config::DISPLAY_TYPE_BOTH;
}
}
// Output: with tax/display/type set to 3 for the store
// showsBothPrices(1) === true
Discussion