catalog)
Baseline: main 23.0+ · Verified: catalog 26.400.0, main 26.800.0
Product ID = iblock element ID. Needs iblock + catalog loaded. Cart, orders, payments → bitrix-sale; elements and properties → bitrix-iblocks.
API choice
| Need | Use |
|---|---|
| Read product/price/store rows | ProductTable, PriceTable, StoreProductTable, CatalogIblockTable (ORM) |
| Write product fields (qty, type, VAT) | Model\Product::add/update — recalculates AVAILABLE, parent SKU type/availability, sets, subscriptions |
| Write prices | Model\Price::add/update/delete — computes PRICE_SCALE, validates currency, recounts parent SKU prices |
| Register catalog iblock | CCatalog::Add/Update (no D7 writer) |
| Legacy, compat only | CCatalogProduct, CPrice, CCatalogSku helpers |
Never write ProductTable/PriceTable directly: PriceTable::add() without PRICE_SCALE fails validation, and direct writes skip availability and SKU recalculation.
Catalog iblocks and SKU
\CCatalog::Add(['IBLOCK_ID' => $productIblockId]); // simple catalog
\CCatalog::Add([
'IBLOCK_ID' => $offersIblockId,
'PRODUCT_IBLOCK_ID' => $productIblockId,
'SKU_PROPERTY_ID' => $skuPropertyId, // E property on the offers iblock, USER_TYPE = PropertyTable::USER_TYPE_SKU, LINK_IBLOCK_ID = products
]);
$info = \CCatalogSku::GetInfoByIBlock($iblockId); // CATALOG_TYPE, PRODUCT_IBLOCK_ID, IBLOCK_ID, SKU_PROPERTY_ID…
CATALOG_TYPE: CCatalogSku::TYPE_CATALOG (D, plain), TYPE_PRODUCT (P, parents only), TYPE_OFFERS (O, offers iblock), TYPE_FULL (X, products + SKU parents). Offers of products: \CCatalogSku::getOffersList($productIds, $iblockId).
Product types ProductTable::TYPE_*: PRODUCT 1, SET 2, SKU 3 (parent with offers), OFFER 4, FREE_OFFER 5 (offer without parent), EMPTY_SKU 6, SERVICE 7 (no stock tracking). Buy the offer (TYPE_OFFER), never the SKU parent.
use Bitrix\Catalog\Model\Product;
use Bitrix\Catalog\ProductTable;
$result = Product::update($elementId, [ // Product::add(['ID' => $elementId, 'TYPE' => ProductTable::TYPE_PRODUCT, …]) for a new row
'QUANTITY' => 10,
'QUANTITY_TRACE' => ProductTable::STATUS_YES,
'CAN_BUY_ZERO' => ProductTable::STATUS_NO,
]);
if (!$result->isSuccess()) { /* $result->getErrorMessages() */ }
Prices
Price types: GroupTable (b_catalog_group); base type GroupTable::getBasePriceTypeId() (cached, ?int). Who may see/buy by type: GroupAccessTable.
use Bitrix\Catalog\GroupTable;
use Bitrix\Catalog\Model\Price;
use Bitrix\Catalog\PriceTable;
$result = Price::add([
'PRODUCT_ID' => $elementId,
'CATALOG_GROUP_ID' => GroupTable::getBasePriceTypeId(),
'PRICE' => 1990.00,
'CURRENCY' => 'RUB', // must exist in the currency module
]);
if (!$result->isSuccess()) { /* $result->getErrorMessages() */ }
$prices = PriceTable::getList([
'select' => ['ID', 'CATALOG_GROUP_ID', 'PRICE', 'CURRENCY', 'QUANTITY_FROM', 'QUANTITY_TO'],
'filter' => ['=PRODUCT_ID' => $elementId],
])->fetchAll();
VAT lives on the product (VAT_ID, VAT_INCLUDED); rates in VatTable. Final buyer prices and discounts are computed by sale (bitrix-sale), not by reading PriceTable.
Stock and stores
- Product level:
QUANTITY,QUANTITY_RESERVED,QUANTITY_TRACE,CAN_BUY_ZERO,AVAILABLEonProductTable. - Stores:
StoreTable+StoreProductTable(STORE_ID,PRODUCT_ID,AMOUNT,QUANTITY_RESERVED). Warehouse operations (receipt, move, write-off) go through store documents (StoreDocumentTable, conduct withCCatalogDocs::conductDocument($docId, $userId),false+$APPLICATION->GetException()on error), not directAMOUNTedits or SQL. - Store rights (inventory management with
crm): mirror what the user may see:
use Bitrix\Catalog\Access\AccessController;
use Bitrix\Catalog\Access\ActionDictionary;
use Bitrix\Catalog\StoreProductTable;
$access = AccessController::getCurrent()
->getEntityFilter(ActionDictionary::ACTION_STORE_VIEW, StoreProductTable::class) ?? [];
$rows = StoreProductTable::getList([
'select' => ['STORE_ID', 'AMOUNT', 'QUANTITY_RESERVED'],
'filter' => [['=PRODUCT_ID' => $elementId], $access], // nested: an OR in user filter cannot bypass access
])->fetchAll();
- Since catalog 26.400 REST
catalog.storeproduct.list/getandcatalog.document.element.listapply this filter. Document rows of forbidden stores returnSTORE_FROM/STORE_TO/AMOUNT/PURCHASING_PRICE=null, and these fields cannot be filtered or sorted. Integrations must not rely on them. Stock rows are not filtered for admins and in legacy mode:AccessController::getCurrent()->isLegacyAccessMode()(Since catalog 26.400; before:!ModuleManager::isModuleInstalled('crm')). Older versions return all stores over REST: filter in the integration.
Checklist
-
iblock+catalogloaded; catalog iblocks registered viaCCatalog::Add, SKU withPRODUCT_IBLOCK_ID+SKU_PROPERTY_ID. - Product and price writes via
Model\Product/Model\Price, everyResultchecked. - Base price type via
GroupTable::getBasePriceTypeId(), no hardcoded IDs. - Offers (not SKU parents) go to the basket.
- Stock read with the store access filter where users are restricted; no raw SQL.
- Batch reads (collect IDs, one query); no
CCatalogProduct::GetByIDin loops.