Стандартных типов свойств в Битриксе хватает ровно до первой задачи вида «привязать комплектующие, но с количеством». Привязка к элементу есть, число есть, а «элемент плюс число в одной строке» — нет, и заказчик не понимает, почему это проблема.
Механизм пользовательских типов свойств закрывает такие случаи, но у него крутая кривая входа: нужно понимать, в какой момент вызывается каждый колбэк и что именно уезжает в базу. Разберём на рабочем примере — и на граблях, которые в нём заложены.
Задача
Создать тип свойства «Привязка к элементу с количеством» — при выборе связанного элемента можно указать дополнительное значение (количество, цену, описание).
Применения:
- Комплектующие товара с количеством
- Опции товара с ценой
- Связанные товары с описанием связи
Проверьте базовый тип до того, как начнёте. В примере ниже PROPERTY_TYPE объявлен как 'E' — привязка к элементу, — но в базу пишется сериализованная структура. Для Битрикса тип E означает, что значением является идентификатор элемента: на нём строятся связи, фильтрация по привязке и, при втором варианте хранения свойств, числовой тип колонки в таблице b_iblock_element_prop_s{IBLOCK_ID}.
Прежде чем брать этот код в проект, проверьте на своём инфоблоке, что структура сохраняется и читается без потерь. Для составного значения безопаснее базовый тип 'S' (строка): он не обещает ядру ничего про содержимое, и никакой логики привязки к нему не привязано. Возможностью выбрать элемент через iblock_element_search.php вы при этом не жертвуете — её даёт ваш собственный GetPropertyFieldHtml, а не базовый тип.
Структура пользовательского типа
<?php
// /local/php_interface/classes/UserType/ElementWithQuantity.php
namespace Local\UserType;
class ElementWithQuantity
{
/**
* Регистрация типа свойства
*/
public static function getUserTypeDescription(): array
{
return [
'PROPERTY_TYPE' => 'E', // Базовый тип — привязка к элементу
'USER_TYPE' => 'ElementWithQuantity',
'DESCRIPTION' => 'Привязка к элементу с количеством',
'GetPropertyFieldHtml' => [self::class, 'getPropertyFieldHtml'],
'GetAdminListViewHTML' => [self::class, 'getAdminListViewHtml'],
'GetPublicViewHTML' => [self::class, 'getPublicViewHtml'],
'ConvertToDB' => [self::class, 'convertToDB'],
'ConvertFromDB' => [self::class, 'convertFromDB'],
'GetSearchContent' => [self::class, 'getSearchContent'],
];
}
/**
* HTML для формы редактирования в админке
*/
public static function getPropertyFieldHtml(
array $property,
array $value,
array $htmlControl
): string {
// Десериализуем данные
$elementId = 0;
$quantity = 1;
if (!empty($value['VALUE'])) {
$data = self::unserializeValue($value['VALUE']);
$elementId = $data['ELEMENT_ID'] ?? 0;
$quantity = $data['QUANTITY'] ?? 1;
}
// Получаем информацию о связанном элементе
$elementName = '';
if ($elementId > 0) {
$element = \CIBlockElement::GetList(
[],
['ID' => $elementId, 'IBLOCK_ID' => $property['LINK_IBLOCK_ID']],
false,
['nTopCount' => 1],
['ID', 'NAME']
)->Fetch();
if ($element) {
$elementName = $element['NAME'];
}
}
$inputName = $htmlControl['VALUE'];
$inputId = md5($inputName);
$html = '<div class="element-with-quantity" style="display: flex; gap: 10px; align-items: center;">';
// Поле выбора элемента
$html .= '<input type="hidden" name="' . $inputName . '[ELEMENT_ID]" id="element_' . $inputId . '" value="' . (int)$elementId . '">';
$html .= '<span id="element_name_' . $inputId . '" style="min-width: 200px;">' . htmlspecialchars($elementName) . '</span>';
// Кнопка выбора
$html .= '<input type="button" value="Выбрать" onclick="jsUtils.OpenWindow(\'/bitrix/admin/iblock_element_search.php?lang=' . LANGUAGE_ID . '&IBLOCK_ID=' . (int)$property['LINK_IBLOCK_ID'] . '&n=' . $inputName . '[ELEMENT_ID]&k=element_name_' . $inputId . '\', 900, 700);">';
// Поле количества
$html .= '<label style="margin-left: 15px;">Кол-во: ';
$html .= '<input type="number" name="' . $inputName . '[QUANTITY]" value="' . (int)$quantity . '" min="1" style="width: 60px;">';
$html .= '</label>';
$html .= '</div>';
return $html;
}
/**
* Отображение в списке админки
*/
public static function getAdminListViewHtml(
array $property,
array $value,
array $htmlControl
): string {
if (empty($value['VALUE'])) {
return '—';
}
$data = self::unserializeValue($value['VALUE']);
$elementId = $data['ELEMENT_ID'] ?? 0;
$quantity = $data['QUANTITY'] ?? 1;
if ($elementId <= 0) {
return '—';
}
$element = \CIBlockElement::GetByID($elementId)->Fetch();
$name = $element ? $element['NAME'] : "ID: {$elementId}";
return htmlspecialchars($name) . ' × ' . $quantity;
}
/**
* Публичное отображение
*/
public static function getPublicViewHtml(
array $property,
array $value,
array $htmlControl
): string {
return self::getAdminListViewHtml($property, $value, $htmlControl);
}
/**
* Преобразование для сохранения в БД
*/
public static function convertToDB(array $property, array $value): array
{
$elementId = (int)($value['VALUE']['ELEMENT_ID'] ?? 0);
$quantity = (int)($value['VALUE']['QUANTITY'] ?? 1);
if ($elementId <= 0) {
return ['VALUE' => '', 'DESCRIPTION' => ''];
}
return [
'VALUE' => serialize([
'ELEMENT_ID' => $elementId,
'QUANTITY' => max(1, $quantity),
]),
'DESCRIPTION' => '',
];
}
/**
* Преобразование при чтении из БД
*/
public static function convertFromDB(array $property, array $value): array
{
if (empty($value['VALUE'])) {
return ['VALUE' => ''];
}
// Значение уже сериализовано — возвращаем как есть
return ['VALUE' => $value['VALUE']];
}
/**
* Контент для поиска
*/
public static function getSearchContent(array $property, array $value, array $htmlControl): string
{
if (empty($value['VALUE'])) {
return '';
}
$data = self::unserializeValue($value['VALUE']);
$elementId = $data['ELEMENT_ID'] ?? 0;
if ($elementId <= 0) {
return '';
}
$element = \CIBlockElement::GetByID($elementId)->Fetch();
return $element ? $element['NAME'] : '';
}
/**
* Десериализация значения
*/
private static function unserializeValue($value): array
{
if (is_array($value)) {
return $value;
}
// allowed_classes => false обязательно: без него unserialize
// восстанавливает объекты и вызывает их магические методы.
// Значение приходит из базы, но в базу оно могло попасть импортом
$unserialized = @unserialize($value, ['allowed_classes' => false]);
return is_array($unserialized) ? $unserialized : [];
}
}Почему JSON вместо serialize
В расширенном примере ниже данные хранятся в JSON, и это не вопрос вкуса.
serialize() умеет восстанавливать объекты, вызывая при этом магические методы — а значит, unserialize() над строкой из базы становится точкой входа для PHP object injection, если в базу удалось что-то записать (импорт, интеграция, любая другая уязвимость). Флаг allowed_classes => false эту дыру закрывает, но нужно не забыть его поставить в каждом вызове.
json_decode() таких возможностей не имеет в принципе — худшее, что он вернёт, это null. Плюс JSON читается глазами прямо в базе, что экономит время при разборе инцидентов, и не ломается при смене версии PHP.
Serialize здесь оставлен намеренно: это формат, который вы встретите в большинстве существующих проектов, и знать про allowed_classes нужно именно для них. В новом коде берите JSON.
Регистрация типа свойства
<?php
// /local/php_interface/init.php
use Bitrix\Main\Loader;
use Bitrix\Main\EventManager;
// Автозагрузка классов
Loader::registerAutoLoadClasses(null, [
'Local\\UserType\\ElementWithQuantity' => '/local/php_interface/classes/UserType/ElementWithQuantity.php',
]);
// Регистрация типа свойства
EventManager::getInstance()->addEventHandler(
'iblock',
'OnIBlockPropertyBuildList',
['\\Local\\UserType\\ElementWithQuantity', 'getUserTypeDescription']
);Использование в коде
Получение данных
<?php
use Bitrix\Main\Loader;
Loader::includeModule('iblock');
$element = \CIBlockElement::GetList(
[],
['ID' => $productId],
false,
false,
['ID', 'NAME', 'PROPERTY_COMPONENTS']
)->GetNext();
// Свойство содержит сериализованные данные
$components = [];
if (!empty($element['PROPERTY_COMPONENTS_VALUE'])) {
$values = is_array($element['PROPERTY_COMPONENTS_VALUE'])
? $element['PROPERTY_COMPONENTS_VALUE']
: [$element['PROPERTY_COMPONENTS_VALUE']];
foreach ($values as $serialized) {
$data = unserialize($serialized);
if (!empty($data['ELEMENT_ID'])) {
$linkedElement = \CIBlockElement::GetByID($data['ELEMENT_ID'])->Fetch();
$components[] = [
'ID' => $data['ELEMENT_ID'],
'NAME' => $linkedElement['NAME'] ?? '',
'QUANTITY' => $data['QUANTITY'] ?? 1,
];
}
}
}
// Вывод
foreach ($components as $component) {
echo "{$component['NAME']} × {$component['QUANTITY']}<br>";
}Сохранение данных
<?php
\CIBlockElement::SetPropertyValuesEx($elementId, $iblockId, [
'COMPONENTS' => [
['VALUE' => ['ELEMENT_ID' => 100, 'QUANTITY' => 2]],
['VALUE' => ['ELEMENT_ID' => 101, 'QUANTITY' => 5]],
],
]);Расширенная версия: с ценой и описанием
<?php
namespace Local\UserType;
class ElementWithOptions
{
public static function getUserTypeDescription(): array
{
return [
'PROPERTY_TYPE' => 'E',
'USER_TYPE' => 'ElementWithOptions',
'DESCRIPTION' => 'Привязка с опциями (кол-во, цена, описание)',
'GetPropertyFieldHtml' => [self::class, 'getPropertyFieldHtml'],
'ConvertToDB' => [self::class, 'convertToDB'],
'ConvertFromDB' => [self::class, 'convertFromDB'],
];
}
public static function getPropertyFieldHtml($property, $value, $htmlControl): string
{
$data = self::parseValue($value['VALUE'] ?? '');
$inputName = $htmlControl['VALUE'];
$inputId = md5($inputName);
$elementName = '';
if ($data['ELEMENT_ID'] > 0) {
$el = \CIBlockElement::GetByID($data['ELEMENT_ID'])->Fetch();
$elementName = $el['NAME'] ?? '';
}
return '
<div style="display: grid; grid-template-columns: 200px auto; gap: 8px; align-items: center; margin-bottom: 10px; padding: 10px; border: 1px solid #ddd; border-radius: 4px;">
<label>Элемент:</label>
<div style="display: flex; gap: 8px; align-items: center;">
<input type="hidden" name="' . $inputName . '[ELEMENT_ID]" id="el_' . $inputId . '" value="' . (int)$data['ELEMENT_ID'] . '">
<span id="el_name_' . $inputId . '">' . htmlspecialchars($elementName) . '</span>
<input type="button" value="..." onclick="jsUtils.OpenWindow(\'/bitrix/admin/iblock_element_search.php?lang=' . LANGUAGE_ID . '&IBLOCK_ID=' . (int)$property['LINK_IBLOCK_ID'] . '&n=' . $inputName . '[ELEMENT_ID]&k=el_name_' . $inputId . '\', 900, 700);">
</div>
<label>Количество:</label>
<input type="number" name="' . $inputName . '[QUANTITY]" value="' . (int)$data['QUANTITY'] . '" min="1" style="width: 80px;">
<label>Цена:</label>
<input type="number" name="' . $inputName . '[PRICE]" value="' . (float)$data['PRICE'] . '" step="0.01" style="width: 100px;">
<label>Описание:</label>
<input type="text" name="' . $inputName . '[DESCRIPTION]" value="' . htmlspecialchars($data['DESCRIPTION']) . '" style="width: 300px;">
</div>';
}
public static function convertToDB($property, $value): array
{
$data = $value['VALUE'];
if (empty($data['ELEMENT_ID'])) {
return ['VALUE' => ''];
}
return [
'VALUE' => json_encode([
'ELEMENT_ID' => (int)$data['ELEMENT_ID'],
'QUANTITY' => max(1, (int)$data['QUANTITY']),
'PRICE' => (float)$data['PRICE'],
'DESCRIPTION' => trim($data['DESCRIPTION'] ?? ''),
], JSON_UNESCAPED_UNICODE),
];
}
public static function convertFromDB($property, $value): array
{
return ['VALUE' => $value['VALUE']];
}
private static function parseValue($value): array
{
$defaults = [
'ELEMENT_ID' => 0,
'QUANTITY' => 1,
'PRICE' => 0,
'DESCRIPTION' => '',
];
if (empty($value)) {
return $defaults;
}
$data = json_decode($value, true);
return is_array($data) ? array_merge($defaults, $data) : $defaults;
}
}Что учесть перед внедрением
Пользовательский тип свойства — решение мощное, но с длинным хвостом последствий. Что стоит проверить до того, как он окажется на боевом каталоге.
Фильтрация и поиск по такому свойству не работают «сами». В базе лежит строка с сериализованными данными: умный фильтр по ней ничего не построит, ORM-фильтр по значению — тоже. Если по свойству нужно фильтровать, продумывайте это на этапе проектирования, а не после.
Формат данных фиксируется навсегда. Как только контент-менеджеры заполнили тысячу товаров, менять структуру можно только вместе со скриптом миграции. Продумайте набор полей заранее и лучше заложите один лишний.
Обмен с 1С про ваш формат не знает. Если инфоблок участвует в обмене, проверьте поведение до внедрения: в лучшем случае свойство будет игнорироваться, в худшем — затираться.
Экспорт, импорт и копирование элементов. Штатные механизмы работают со значением как со строкой. Обычно это то, что нужно, но проверить стоит — особенно копирование элемента, которым контент-менеджеры пользуются постоянно.
GetPublicViewHTML в примере возвращает то же, что админский метод. Для публичной части это почти всегда неверно: разметка и вёрстка витрины отличаются от админской. Либо реализуйте метод отдельно, либо не выводите свойство напрямую, а разбирайте значение в шаблоне компонента.
Прежде чем писать свой тип, оцените альтернативу. Часто задачу «элемент плюс количество» закрывает пара обычных множественных свойств с согласованным порядком значений или отдельный HL-блок со связями. Выглядит менее элегантно, зато работает со штатной фильтрацией, обменом и экспортом без единой строки кода. Собственный тип свойства оправдан, когда важно именно удобство ввода в админке и структура действительно неразделима.
Комментарии
Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.