# Правила написания кода PHP-команды
> Битрикс24 · ORM · SOLID · DDD
> Полное руководство — версия 2.0
**Платформа:** Битрикс24
**PHP:** 8.1+ / 8.4
**Версия документа:** 2.0 (исправленная и дополненная)
**Год:** 2026
SOLID · DRY · KISS · YAGNI · ORM Bitrix · DTO · Repository · PSR-3 · PSR-12 · PHP 8.1+ · AbstractAgent
---
## Содержание
01. Именование и читаемость
02. Функции и методы
03. Классы и объекты (SOLID, DI, ServiceLocator)
04. Контроллеры
05. Работа с массивами и коллекциями
06. Условия и управляющие конструкции
07. Типизация и строгость
08. Обработка ошибок и Result Object
09. Безопасность
10. Базы данных и ORM Битрикс
11. Производительность: кеш и TaggedCache
12. Тестирование
13. Комментирование и документация
14. Общие практики (DRY, KISS, YAGNI)
15. События Bitrix (EventManager)
16. Логирование (PSR-3)
17. Агенты — AbstractAgent (полный паттерн)
---
## РАЗДЕЛ 01. Именование и читаемость
### Осмысленные имена переменных, методов, классов
Избегайте сокращений (кроме общепринятых: id, url, json), однобуквенных имён и абстрактных названий. Имя должно отражать суть сущности или действия.
**ПЛОХО**
```php
function calc($d) {
$r = [];
foreach ($d as $x) {
if ($x['UF_ACTIVE']) {
$r[] = $x['UF_NAME'];
}
}
return $r;
}
```
**ХОРОШО**
```php
/**
* Возвращает имена активных клиентов.
* @param array $clients
* @return string[]
*/
function getActiveClientNames(array $clients): array {
$names = [];
foreach ($clients as $client) {
if ($client['UF_ACTIVE'] === 'Y') {
$names[] = $client['UF_FULL_NAME'];
}
}
return $names;
}
```
### Единый стиль именования (PSR-12 + Bitrix-соглашения)
| Сущность | Стиль | Пример |
|---|---|---|
| Классы | PascalCase | `ClientService` |
| Методы / функции | camelCase | `getActiveClients()` |
| Константы класса | UPPER_CASE | `MAX_RETRY_COUNT` |
| Свойства | camelCase | `$firstName` |
| Поля ORM Bitrix | snake_case (как в БД) | `STAGE_ID`, `UF_ACTIVE` |
Пользовательские поля `UF_*` — как есть: `UF_FULL_NAME`
---
## РАЗДЕЛ 02. Функции и методы
### Одна функция — одна ответственность (SRP)
**ПЛОХО**
```php
function processLeadAndSendNotification(array $data): void {
// валидация, сохранение в CRM, отправка письма, логирование — всё в одном месте
}
```
**ХОРОШО**
```php
function validateLeadData(array $data): array { /* ... */ }
function createLead(array $validatedData): \Bitrix\Crm\Item { /* ... */ }
function sendLeadNotification(\Bitrix\Crm\Item $lead): void { /* ... */ }
```
### Избегайте флаговых аргументов (boolean trap)
**ПЛОХО**
```php
function addProductToDeal(int $dealId, array $productRow, bool $recalculate = true): void {
// добавление товара
if ($recalculate) {
\CCrmDeal::Recalculate($dealId);
}
}
```
**ХОРОШО — ДВА ЯВНЫХ МЕТОДА**
```php
function addProductToDeal(int $dealId, array $productRow): void {
// только добавление товара
}
function addProductToDealAndRecalculate(int $dealId, array $productRow): void {
$this->addProductToDeal($dealId, $productRow);
\CCrmDeal::Recalculate($dealId);
}
```
### Количество параметров ≤ 3–4, иначе — DTO
**ПЛОХО**
```php
function updateCompany(int $id, string $title, string $phone, string $email, ?string $address, bool $isActive) { /* ... */ }
```
**ХОРОШО — READONLY DTO (PHP 8.2+)**
```php
readonly class UpdateCompanyDto {
public function __construct(
public string $title,
public string $phone,
public string $email,
public ?string $address = null,
public bool $isActive = true,
) {}
}
function updateCompany(int $id, UpdateCompanyDto $dto): void { /* ... */ }
```
### Ранний возврат (early return)
**ПЛОХО — ГЛУБОКАЯ ВЛОЖЕННОСТЬ**
```php
function getDiscountForDeal(\Bitrix\Crm\Item $deal): float {
$discount = 0.0;
if ($deal->getStageId() === 'EXECUTING') {
$client = $deal->getClient();
if ($client && $client->hasActiveSubscription()) {
if ($deal->getOpportunity() > 100000) {
$discount = 0.15;
} else {
$discount = 0.05;
}
}
}
return $discount;
}
```
**ХОРОШО — FLAT-СТРУКТУРА**
```php
function getDiscountForDeal(\Bitrix\Crm\Item $deal): float {
if ($deal->getStageId() !== 'EXECUTING') {
return 0.0;
}
$client = $deal->getClient();
if (!$client || !$client->hasActiveSubscription()) {
return 0.0;
}
return $deal->getOpportunity() > 100000 ? 0.15 : 0.05;
}
```
---
## РАЗДЕЛ 03. Классы и объекты (SOLID, DI, ServiceLocator)
### Принцип единственной ответственности (SRP)
**ПЛОХО — КЛАСС ДЕЛАЕТ ВСЁ СРАЗУ**
```php
class LeadManager {
public function create(array $data): void { /* ... */ }
public function sendEmail(string $message): void { /* ... */ }
public function log(string $action): void { /* ... */ }
}
```
**ХОРОШО — РАЗДЕЛЕНИЕ ОТВЕТСТВЕННОСТЕЙ**
```php
class LeadCreationService {
public function __construct(
private \Bitrix\Crm\Service\Container $crmContainer,
private LeadNotificationService $notificationService,
private \Psr\Log\LoggerInterface $logger,
) {}
public function createFromArray(array $data): \Bitrix\Crm\Item {
// только логика создания лида
}
}
```
### Предпочитайте композицию наследованию
**ПЛОХО — НАСЛЕДОВАНИЕ ОТ УСТАРЕВШЕГО КЛАССА**
```php
class CustomDealProcessor extends \CCrmDeal {
public function process($dealId) {
$this->Update($dealId, ['STAGE_ID' => 'WON']);
}
}
```
**ПРАВИЛЬНО — ЧЕРЕЗ CONTAINER**
```php
class DealProcessor {
public function __construct(
private \Bitrix\Crm\Service\Container $container,
) {}
public function markAsWon(int $dealId): void {
$factory = $this->container->getFactory(\CCrmOwnerType::Deal);
$deal = $factory->getItem($dealId);
if ($deal === null) {
throw new \Bitrix\Main\ObjectNotFoundException("Deal #{$dealId} not found");
}
$deal->setStageId('WON');
$result = $factory->getUpdateOperation($deal)->launch();
if (!$result->isSuccess()) {
throw new \RuntimeException(implode(', ', $result->getErrorMessages()));
}
}
}
```
### ServiceLocator Bitrix для внедрения зависимостей
**ПЛОХО — РУЧНОЕ СОЗДАНИЕ ЗАВИСИМОСТЕЙ ПОВСЮДУ**
```php
// В каждом месте использования:
$logger = new FileLogger('/local/logs/app.log');
$factory = \Bitrix\Crm\Service\Container::getInstance()
->getFactory(\CCrmOwnerType::Lead);
$service = new LeadCreationService($factory, new LeadNotificationService(), $logger);
```
**ХОРОШО — РЕГИСТРАЦИЯ ОДИН РАЗ, ПОЛУЧЕНИЕ ВЕЗДЕ**
```php
use Bitrix\Main\DI\ServiceLocator;
// Один раз:
$serviceLocator = ServiceLocator::getInstance();
$serviceLocator->addInstanceLazy(
LeadCreationService::class,
static function () use ($serviceLocator): LeadCreationService {
return new LeadCreationService(
\Bitrix\Crm\Service\Container::getInstance(),
$serviceLocator->get(LeadNotificationService::class),
$serviceLocator->get(\Psr\Log\LoggerInterface::class),
);
}
);
// Получение в любом месте приложения:
$service = ServiceLocator::getInstance()->get(LeadCreationService::class);
```
### Избегайте статических методов для бизнес-логики
**ПЛОХО — ПРЯМОЙ ВЫЗОВ УСТАРЕВШЕГО API**
```php
$leadId = \CCrmLead::Add(['TITLE' => 'Новый лид']);
```
**ХОРОШО — ОБОРАЧИВАЕМ В СЕРВИС**
```php
class LeadService {
public function __construct(private \Bitrix\Crm\Service\Factory $factory) {}
public function create(string $title): \Bitrix\Crm\Item {
$lead = $this->factory->createItem();
$lead->setTitle($title);
return $this->factory->getCreateOperation($lead)->launch()->getItem();
}
}
```
---
## РАЗДЕЛ 04. Контроллеры
### Тонкие контроллеры — вся логика в сервисах
**ПЛОХО — ЛОГИКА ПРЯМО В КОМПОНЕНТЕ**
```php
// result_modifier.php
if ($arParams['USE_FILTER'] == 'Y') {
$filter = ['ACTIVE' => 'Y'];
if ($_REQUEST['search']) {
$filter['%NAME'] = $_REQUEST['search'];
}
$res = \CIBlockElement::GetList([], $filter, false, false, ['ID','NAME']);
while ($el = $res->Fetch()) {
$arResult['ITEMS'][] = $el;
}
}
```
**ХОРОШО — СЕРВИС С КОРРЕКТНЫМ ORM API**
```php
class NewsService {
public function getFilteredNews(array $criteria): array {
$query = \Bitrix\Iblock\Elements\ElementNewsTable::query()
->setFilter(['=ACTIVE' => 'Y'])
->setSelect(['ID', 'NAME']);
if (!empty($criteria['search'])) {
$query->whereLike('NAME', '%' . $criteria['search'] . '%');
}
return $query->exec()->fetchAll();
}
}
// В компоненте:
$arResult['ITEMS'] = (new NewsService())->getFilteredNews([
'search' => $_REQUEST['search'] ?? '',
]);
```
### DTO для валидации входящих данных
**ПЛОХО — СЫРАЯ ВАЛИДАЦИЯ В КОНТРОЛЛЕРЕ**
```php
$title = $_POST['TITLE'] ?? '';
if (strlen($title) < 3) {
$error = 'Заголовок слишком короткий';
} else {
\CCrmLead::Add(['TITLE' => $title]);
}
```
**ХОРОШО — DTO + КОНТРОЛЛЕР**
```php
readonly class CreateLeadDto {
public function __construct(
public string $title,
public ?string $phone = null,
public ?string $email = null,
) {}
/** @throws \InvalidArgumentException */
public static function fromArray(array $data): self {
if (empty($data['TITLE']) || mb_strlen($data['TITLE']) < 3) {
throw new \InvalidArgumentException('Заголовок — не менее 3 символов');
}
return new self(
title: trim($data['TITLE']),
phone: $data['PHONE'] ?? null,
email: $data['EMAIL'] ?? null,
);
}
}
class LeadController {
public function __construct(private LeadCreationService $leadService) {}
public function createAction(\Bitrix\Main\Request $request): \Bitrix\Main\Response {
try {
$dto = CreateLeadDto::fromArray($request->getPostList()->toArray());
$lead = $this->leadService->create($dto);
return new \Bitrix\Main\Response(
json_encode(['id' => $lead->getId()]),
'application/json'
);
} catch (\InvalidArgumentException $e) {
return new \Bitrix\Main\Response(
json_encode(['error' => $e->getMessage()]),
'application/json',
400
);
}
}
}
```
---
## РАЗДЕЛ 05. Работа с массивами и коллекциями
### Встроенные функции вместо foreach-циклов
**ПЛОХО**
```php
$activeClientIds = [];
foreach ($clients as $client) {
if ($client['UF_ACTIVE'] === 'Y') {
$activeClientIds[] = $client['ID'];
}
}
```
**ХОРОШО**
```php
$activeClients = array_filter($clients, fn($c) => $c['UF_ACTIVE'] === 'Y');
$activeClientIds = array_column($activeClients, 'ID');
```
### Вложенные массивы — разворачивание через array_merge / array_column
**ПЛОХО — ВЛОЖЕННЫЕ ЦИКЛЫ**
```php
$allPhones = [];
foreach ($contacts as $contact) {
foreach ($contact['PHONE'] as $phone) {
$allPhones[] = $phone['VALUE'];
}
}
```
**ХОРОШО**
```php
$allPhones = array_merge(...array_column($contacts, 'PHONE'));
$phoneNumbers = array_column($allPhones, 'VALUE');
```
### Коллекции объектов для сложных структур
**ПЛОХО — РУЧНОЙ ЦИКЛ ДЛЯ АГРЕГАЦИИ**
```php
$sum = 0;
foreach ($dealProducts as $row) {
$sum += $row['PRICE'] * $row['QUANTITY'];
}
```
**ХОРОШО — ILLUMINATE/COLLECTIONS**
```php
use Illuminate\Support\Collection;
$totalSum = Collection::make($dealProducts)
->sum(fn($row) => $row['PRICE'] * $row['QUANTITY']);
```
---
## РАЗДЕЛ 06. Условия и управляющие конструкции
### Длинные условия — выносите в методы
**ПЛОХО — НЕЧИТАЕМОЕ УСЛОВИЕ В IF**
```php
if ($deal->getStageId() === 'EXECUTING' && $deal->getOpportunity() > 10000 && $deal->getAssignedById() === $currentUserId && !$deal->getClosed()) {
// разрешить действие
}
```
**ХОРОШО — ИНКАПСУЛИРОВАННОЕ УСЛОВИЕ**
```php
private function canCurrentUserEditDeal(\Bitrix\Crm\Item $deal): bool {
return $deal->getStageId() === 'EXECUTING'
&& $deal->getOpportunity() > 10000
&& $deal->getAssignedById() === \CCrmSecurityHelper::GetCurrentUserID()
&& !$deal->getClosed();
}
if ($this->canCurrentUserEditDeal($deal)) {
// разрешить действие
}
```
### Именованные константы и Enum вместо магических значений
**ПЛОХО — МАГИЧЕСКИЕ ЧИСЛА И СТРОКИ**
```php
if ($lead->getStatusId() === '1') { // что за статус?
$action = 'call';
}
if ($product['QUANTITY'] < 10) { /* неясно откуда 10 */ }
```
**ХОРОШО — PHP 8.1+ ENUM**
```php
enum LeadStatus: string {
case NEW = '1';
case IN_PROCESS = '2';
case CONVERTED = '3';
}
const LOW_STOCK_THRESHOLD = 10;
if ($lead->getStatusId() === LeadStatus::NEW->value) {
$action = 'call';
}
if ($product['QUANTITY'] < LOW_STOCK_THRESHOLD) { /* ... */ }
```
### Полиморфизм вместо switch / if-elseif
**ПЛОХО — РАЗРАСТАЮЩИЙСЯ SWITCH**
```php
class ActivityHandler {
public function process($activityType, $data) {
if ($activityType === 'CALL') {
// логика звонка
} elseif ($activityType === 'EMAIL') {
// логика письма
} elseif ($activityType === 'MEETING') {
// логика встречи — и так далее...
} else {
throw new \Exception('Unknown type');
}
}
}
```
**ХОРОШО — ПАТТЕРН STRATEGY + REGISTRY**
```php
interface ActivityProcessor {
public function process(array $data): void;
}
class CallProcessor implements ActivityProcessor { /* ... */ }
class EmailProcessor implements ActivityProcessor { /* ... */ }
class ActivityProcessorRegistry {
private array $processors = [];
public function register(string $type, ActivityProcessor $p): void {
$this->processors[$type] = $p;
}
public function get(string $type): ActivityProcessor {
return $this->processors[$type]
?? throw new \InvalidArgumentException("Unknown activity type: {$type}");
}
}
```
### match вместо if-elseif (PHP 8+)
**ПЛОХО**
```php
if ($stage === 'NEW') {
$label = 'Новый';
} elseif ($stage === 'IN_PROGRESS') {
$label = 'В работе';
} else {
$label = 'Завершён';
}
```
**ХОРОШО — MATCH СО СТРОГИМ СРАВНЕНИЕМ**
```php
$label = match ($stage) {
'NEW' => 'Новый',
'IN_PROGRESS' => 'В работе',
default => 'Завершён',
};
```
---
## РАЗДЕЛ 07. Типизация и строгость
### declare(strict_types=1) — обязательно в каждом файле
**ПЛОХО — НЕЯВНОЕ ПРИВЕДЕНИЕ ТИПОВ**
```php
<?php
// нет strict_types — PHP молча приведёт "5" к int
function add(int $a, int $b): int {
return $a + $b;
}
echo add("5", "10"); // 15 — ошибка замаскирована
```
**ХОРОШО — ЯВНЫЙ TYPEERROR ПРИ НЕВЕРНЫХ ТИПАХ**
```php
<?php
declare(strict_types=1);
function add(int $a, int $b): int {
return $a + $b;
}
echo add("5", "10"); // TypeError — сразу видим проблему
```
### Типизация свойств, аргументов и возвращаемых значений
**ПЛОХО — PHPDOC ВМЕСТО НАТИВНОЙ ТИПИЗАЦИИ**
```php
class ClientInfo {
/** @var string|null */
private $name;
public function setName($name) {
$this->name = $name;
}
public function getName() {
return $this->name;
}
}
```
**ХОРОШО — НАТИВНАЯ ТИПИЗАЦИЯ PHP 8**
```php
class ClientInfo {
private ?string $name = null;
public function setName(string $name): void {
$this->name = $name;
}
public function getName(): ?string {
return $this->name;
}
}
```
### readonly классы для DTO (PHP 8.2+)
**ПЛОХО — ИЗМЕНЯЕМЫЙ МАССИВ ВМЕСТО DTO**
```php
function updateClient(int $id, array $data): void {
$client = $this->getById($id);
$client['NAME'] = $data['NAME'] ?? $client['NAME'];
// легко ошибиться с ключом, нет типобезопасности
}
```
**ХОРОШО — НЕИЗМЕНЯЕМЫЙ READONLY DTO**
```php
readonly class UpdateClientDto {
public function __construct(
public string $name,
public ?string $phone = null,
public ?string $email = null,
) {}
}
function updateClient(int $id, UpdateClientDto $dto): void {
$client = $this->getById($id);
$client->setName($dto->name);
}
```
---
## РАЗДЕЛ 08. Обработка ошибок и Result Object
### Исключения вместо возврата false/null
**ПЛОХО — СТАРЫЙ ПОДХОД BITRIX С ГЛОБАЛЬНЫМ СОСТОЯНИЕМ**
```php
$leadId = \CCrmLead::Add(['TITLE' => '']);
if (!$leadId) {
$error = \CCrmLead::GetLastError(); // глобальное состояние
}
```
**ХОРОШО — ДОМЕННЫЕ ИСКЛЮЧЕНИЯ**
```php
class LeadCreationException extends \RuntimeException {}
try {
$lead = $this->leadService->createFromDto($dto);
} catch (LeadCreationException $e) {
$this->logger->error($e->getMessage());
}
```
### Иерархия доменных исключений
**ПЛОХО — БАЗОВЫЙ \EXCEPTION БЕЗ КОНТЕКСТА**
```php
throw new \Exception('Неверный email');
// caller не знает, какое именно исключение ловить
```
**ХОРОШО — ИЕРАРХИЯ ДОМЕННЫХ ИСКЛЮЧЕНИЙ**
```php
class MyModuleException extends \RuntimeException {}
class InvalidEmailException extends \DomainException {}
class LeadNotFoundException extends \Bitrix\Main\ObjectNotFoundException {}
class LeadValidationException extends MyModuleException {}
throw new InvalidEmailException('Email имеет неверный формат: ' . $email);
```
### Нативный Result Object Bitrix
Result Object — нативный паттерн Bitrix для операций уровня сервиса. Вы можете использовать его там, где нужно вернуть данные или набор ошибок без исключений.
**ПЛОХО — ВОЗВРАТ NULL ПРИ ОШИБКЕ, НЕТ ДЕТАЛЕЙ**
```php
public function create(CreateLeadDto $dto): ?int {
$lead = $factory->createItem();
$lead->setTitle($dto->title);
$result = $factory->getCreateOperation($lead)->launch();
if (!$result->isSuccess()) {
return null; // caller не знает, что именно пошло не так
}
return $lead->getId();
}
```
**ХОРОШО — RESULT С ДЕТАЛЯМИ ОШИБОК**
```php
public function create(CreateLeadDto $dto): \Bitrix\Main\Result {
$result = new \Bitrix\Main\Result();
try {
$factory = \Bitrix\Crm\Service\Container::getInstance()
->getFactory(\CCrmOwnerType::Lead);
$lead = $factory->createItem();
$lead->setTitle($dto->title);
$opResult = $factory->getCreateOperation($lead)->launch();
if (!$opResult->isSuccess()) {
return $result->addErrors($opResult->getErrors());
}
$result->setData(['id' => $lead->getId()]);
} catch (\Throwable $e) {
$result->addError(new \Bitrix\Main\Error($e->getMessage(), $e->getCode()));
}
return $result;
}
// Использование:
$result = $leadService->create($dto);
if ($result->isSuccess()) {
$leadId = $result->getData()['id'];
} else {
foreach ($result->getErrors() as $error) {
$logger->error($error->getMessage());
}
}
```
---
## РАЗДЕЛ 09. Безопасность
### ORM вместо raw SQL — никаких переменных в строке запроса
**ПЛОХО — SQL-ИНЪЕКЦИЯ ДАЖЕ С FORSQL**
```php
global $DB;
$email = $_GET['email'];
$sql = "SELECT * FROM b_user WHERE EMAIL = '" . $DB->ForSql($email) . "'";
$res = $DB->Query($sql);
```
**ХОРОШО — ORM С ПАРАМЕТРИЗОВАННЫМ ЗАПРОСОМ**
```php
use Bitrix\Main\UserTable;
$email = $_GET['email'] ?? '';
$user = UserTable::query()
->setSelect(['ID', 'NAME', 'LAST_NAME'])
->where('EMAIL', $email) // параметр передаётся безопасно
->setLimit(1)
->exec()
->fetch();
```
### Экранирование вывода в HTML
**ПЛОХО — XSS УЯЗВИМОСТЬ**
```php
echo "Привет, " . $arUser['NAME'];
```
**ХОРОШО — HTMLFILTER::ENCODE()**
```php
use Bitrix\Main\Text\HtmlFilter;
echo "Привет, " . HtmlFilter::encode($arUser['NAME']);
```
### Валидация входящих данных
`FILTER_SANITIZE_SPECIAL_CHARS` и `FILTER_SANITIZE_STRING` удалены в PHP 8.1–8.2. Используйте явную санитизацию.
**ПЛОХО — УДАЛЁННЫЕ КОНСТАНТЫ PHP**
```php
// FILTER_SANITIZE_SPECIAL_CHARS удалён в PHP 8.2!
$phone = filter_input(INPUT_POST, 'PHONE', FILTER_SANITIZE_SPECIAL_CHARS);
```
**ХОРОШО — ЯВНАЯ САНИТИЗАЦИЯ ДЛЯ PHP 8.2+**
```php
$phone = trim((string)($_POST['PHONE'] ?? ''));
$phone = strip_tags($phone);
if (!preg_match('/^\+?\d{10,15}$/', $phone)) {
throw new \InvalidArgumentException('Некорректный номер телефона');
}
// Для email — нативный PHP-фильтр (не удалён):
$email = filter_input(INPUT_POST, 'EMAIL', FILTER_VALIDATE_EMAIL);
if ($email === false || $email === null) {
throw new \InvalidArgumentException('Некорректный email');
}
```
---
## РАЗДЕЛ 10. Базы данных и ORM Битрикс
### Репозиторий инкапсулирует работу с данными
**ПЛОХО — ORM-ЗАПРОСЫ РАЗБРОСАНЫ ПО СЕРВИСАМ**
```php
class DealReportService {
public function getWonDealsSum(): float {
// прямой вызов ORM из сервиса без абстракции
$result = \Bitrix\Crm\DealTable::getList([
'filter' => ['=STAGE_SEMANTIC_ID' => 'S'],
'select' => ['OPPORTUNITY_SUM'],
'runtime' => [
new \Bitrix\Main\Entity\ExpressionField(
'OPPORTUNITY_SUM',
'SUM(%s)',
'OPPORTUNITY'
)
]
])->fetch();
return (float)($result['OPPORTUNITY_SUM'] ?? 0);
}
}
```
**ХОРОШО — ПАТТЕРН REPOSITORY**
```php
interface DealRepository {
public function getTotalWonOpportunity(): float;
}
class OrmDealRepository implements DealRepository {
public function getTotalWonOpportunity(): float {
$result = \Bitrix\Crm\DealTable::query()
->addSelect(
new \Bitrix\Main\Entity\ExpressionField(
'SUM_OPP',
'SUM(%s)',
'OPPORTUNITY'
)
)
->where('STAGE_SEMANTIC_ID', 'S')
->exec()->fetch();
return (float)($result['SUM_OPP'] ?? 0);
}
}
class DealReportService {
public function __construct(private DealRepository $dealRepo) {}
public function getWonDealsSum(): float {
return $this->dealRepo->getTotalWonOpportunity();
}
}
```
### Транзакции для согласованности данных
**ПЛОХО — НЕТ ТРАНЗАКЦИИ, ДАННЫЕ МОГУТ РАССОГЛАСОВАТЬСЯ**
```php
\Bitrix\Crm\DealTable::update($dealId, ['STAGE_ID' => 'WON']);
\Bitrix\Crm\InvoiceTable::add([/* ... */]); // если упадёт — сделка уже обновлена
```
**ХОРОШО — АТОМАРНОЕ ОБНОВЛЕНИЕ С ROLLBACK**
```php
$connection = \Bitrix\Main\Application::getConnection();
$connection->startTransaction();
try {
\Bitrix\Crm\DealTable::update($dealId, ['STAGE_ID' => 'WON']);
\Bitrix\Crm\InvoiceTable::add([/* ... */]);
$connection->commitTransaction();
} catch (\Exception $e) {
$connection->rollbackTransaction();
throw $e;
}
```
### Решение проблемы N+1 через JOIN в ORM
**ПЛОХО — N+1 ЗАПРОСОВ**
```php
$deals = \Bitrix\Crm\DealTable::getList(['select' => ['ID','CONTACT_ID']])->fetchAll();
foreach ($deals as $deal) {
// N дополнительных запросов!
$contact = \Bitrix\Crm\ContactTable::getByPrimary($deal['CONTACT_ID'])->fetch();
}
```
**ХОРОШО — ПРЕДЗАГРУЗКА ЧЕРЕЗ REGISTERRUNTIMEFIELD**
```php
$deals = \Bitrix\Crm\DealTable::query()
->setSelect(['ID', 'TITLE', 'CONTACT.NAME', 'CONTACT.LAST_NAME'])
->registerRuntimeField(
new \Bitrix\Main\Entity\ReferenceField(
'CONTACT',
\Bitrix\Crm\ContactTable::class,
\Bitrix\Main\ORM\Query\Join::on('this.CONTACT_ID', 'ref.ID'),
)
)
->where('STAGE_ID', 'EXECUTING')
->setOrder(['DATE_CREATE' => 'DESC'])
->exec()->fetchCollection();
foreach ($deals as $deal) {
$name = $deal->get('CONTACT.NAME'); // уже загружен, доп. запросов нет
}
```
### fetchCollection() и работа с объектами
**ПЛОХО — НЕСУЩЕСТВУЮЩИЙ МЕТОД КОЛЛЕКЦИИ**
```php
$leadCollection = \Bitrix\Crm\LeadTable::getList(['select' => ['*']])->fetchCollection();
$titles = $leadCollection->getTitleList(); // метод не существует!
```
**ХОРОШО — ГЕТТЕРЫ ЧЕРЕЗ GETALL()**
```php
$leadCollection = \Bitrix\Crm\LeadTable::query()
->setSelect(['ID', 'TITLE'])
->exec()->fetchCollection();
$titles = array_map(
fn($lead) => $lead->getTitle(),
$leadCollection->getAll(),
);
```
---
## РАЗДЕЛ 11. Производительность: кеш и TaggedCache
### Кеширование через Bitrix\Main\Data\Cache
**ПЛОХО — ДОРОГОЙ ЗАПРОС БЕЗ КЕША**
```php
// Выполняется на каждый запрос страницы
$elements = \CIBlockElement::GetList(
[], ['IBLOCK_ID' => $iblockId], false, false,
['ID','NAME']
)->FetchAll();
```
**ХОРОШО — РЕЗУЛЬТАТ КЕШИРУЕТСЯ**
```php
$cache = \Bitrix\Main\Data\Cache::createInstance();
$cacheId = 'iblock_elements_' . $iblockId;
if ($cache->initCache(3600, $cacheId, '/my_module/')) {
$elements = $cache->getVars();
} elseif ($cache->startDataCache()) {
$elements = \Bitrix\Iblock\Elements\ElementNewsTable::getList([
'filter' => ['IBLOCK_ID' => $iblockId],
])->fetchAll();
$cache->endDataCache($elements);
}
```
### Тегированный кеш (TaggedCache)
TaggedCache позволяет инвалидировать кеш при изменении конкретных сущностей. Предпочтительный подход для данных CRM и Iblock.
**ПЛОХО — СБРОС ВСЕГО КЕША ПРИ ИЗМЕНЕНИИ ОДНОГО ЛИДА**
```php
// При любом изменении сбрасываем весь кеш модуля
\Bitrix\Main\Data\Cache::createInstance()->cleanDir('/crm/leads/');
```
**ХОРОШО — ТОЧЕЧНАЯ ИНВАЛИДАЦИЯ ПО ТЕГУ**
```php
$taggedCache = \Bitrix\Main\Application::getInstance()->getTaggedCache();
$cache = \Bitrix\Main\Data\Cache::createInstance();
if ($cache->initCache(3600, 'lead_' . $leadId, '/crm/leads/')) {
$data = $cache->getVars();
} elseif ($cache->startDataCache()) {
$taggedCache->startTagCache('/crm/leads/');
$taggedCache->registerTag('crm_lead_' . $leadId);
$taggedCache->registerTag('crm_leads_list');
$data = $this->leadRepository->getById($leadId);
$taggedCache->endTagCache();
$cache->endDataCache($data);
}
// При изменении лида — только его кеш:
$taggedCache->clearByTag('crm_lead_' . $leadId);
// При массовом изменении:
$taggedCache->clearByTag('crm_leads_list');
```
### Выбирайте только нужные поля
**ПЛОХО — SELECT \* БЕЗ НЕОБХОДИМОСТИ**
```php
$leads = \Bitrix\Crm\LeadTable::getList(['select' => ['*']])->fetchAll();
```
**ХОРОШО — ТОЛЬКО НЕОБХОДИМЫЕ ПОЛЯ**
```php
$leads = \Bitrix\Crm\LeadTable::query()
->setSelect(['ID', 'TITLE', 'STATUS_ID', 'DATE_CREATE'])
->setLimit(50)
->exec()->fetchAll();
```
---
## РАЗДЕЛ 12. Тестирование
### Код должен быть тестируемым — изолируйте зависимости
**ПЛОХО — ПРЯМОЙ ВЫЗОВ API, НЕЛЬЗЯ ПРОТЕСТИРОВАТЬ**
```php
class LeadService {
public function create(array $data): int {
return \CCrmLead::Add($data); // нельзя замокать
}
}
```
**ХОРОШО — РЕПОЗИТОРИЙ ЧЕРЕЗ ИНТЕРФЕЙС**
```php
interface LeadRepository {
public function save(Lead $lead): void;
}
class LeadService {
public function __construct(private LeadRepository $repo) {}
public function create(LeadDto $dto): Lead {
$lead = new Lead($dto);
$this->repo->save($lead);
return $lead;
}
}
// PHPUnit тест:
class LeadServiceTest extends \PHPUnit\Framework\TestCase {
public function testCreateLead(): void {
$mockRepo = $this->createMock(LeadRepository::class);
$mockRepo->expects($this->once())->method('save');
$service = new LeadService($mockRepo);
$lead = $service->create(new LeadDto(title: 'Test Lead'));
$this->assertSame('Test Lead', $lead->getTitle());
}
}
```
### Data Providers для параметризованных тестов
**ПЛОХО — ДУБЛИРОВАНИЕ ТЕСТОВЫХ МЕТОДОВ**
```php
public function testSmallOrderDiscount(): void {
$this->assertSame(0.0, (new DiscountCalculator())->calculate(5000.0));
}
public function testMediumOrderDiscount(): void {
$this->assertSame(0.05, (new DiscountCalculator())->calculate(50000.0));
}
// и так далее...
```
**ХОРОШО — @DATAPROVIDER**
```php
class DiscountCalculatorTest extends \PHPUnit\Framework\TestCase {
/** @dataProvider discountProvider */
public function testCalculate(float $amount, float $expected): void {
$this->assertSame($expected, (new DiscountCalculator())->calculate($amount));
}
public static function discountProvider(): array {
return [
'small order' => [5000.0, 0.0],
'medium order' => [50000.0, 0.05],
'large order' => [150000.0, 0.15],
];
}
}
```
---
## РАЗДЕЛ 13. Комментирование и документация
### Код должен быть самодокументированным
Комментарии объясняют *«почему»*, а не *«что»*. Используйте выразительные имена.
**ПЛОХО — КОММЕНТАРИЙ ОПИСЫВАЕТ ОЧЕВИДНОЕ**
```php
// умножаем цену на количество
$sum = $price * $qty;
```
**ХОРОШО — ИМЯ ГОВОРИТ САМО ЗА СЕБЯ, КОММЕНТАРИЙ ОБЪЯСНЯЕТ БИЗНЕС-ПРАВИЛО**
```php
$totalPrice = $product->getPrice() * $product->getQuantity();
// Скидка для VIP-клиентов введена с Q1 2024 (см. задачу PROJ-1234)
if ($client->isVip()) {
$totalPrice *= (1 - self::VIP_DISCOUNT_RATE);
}
```
### PHPDoc для публичных API
**ПЛОХО — НЕТ ДОКУМЕНТАЦИИ, НЕЯСНЫ ТИПЫ И ИСКЛЮЧЕНИЯ**
```php
public function find($id) {
// ...
}
```
**ХОРОШО — ПОЛНЫЙ PHPDOC**
```php
/**
* Находит сделку по идентификатору.
*
* @param positive-int $id Идентификатор сделки в CRM
* @return \Bitrix\Crm\Item
*
* @throws \Bitrix\Main\ObjectNotFoundException если сделка не найдена
* @throws \Bitrix\Main\SystemException при ошибке базы данных
*/
public function find(int $id): \Bitrix\Crm\Item { /* ... */ }
```
---
## РАЗДЕЛ 14. Общие практики (DRY, KISS, YAGNI, Закон Деметры)
### DRY — не дублируйте знания
**ПЛОХО — ОДНА И ТА ЖЕ ЛОГИКА В ДВУХ МЕСТАХ**
```php
// В компоненте A:
$lead = \Bitrix\Crm\LeadTable::getByPrimary($id)->fetchObject();
if (!$lead) {
throw new \Exception('Лид не найден');
}
// В компоненте B:
$lead = \Bitrix\Crm\LeadTable::getByPrimary($id)->fetchObject();
if (!$lead) {
throw new \Exception('Лид не найден');
}
```
**ХОРОШО — ОБЩАЯ ЛОГИКА В LEADPROVIDER**
```php
class LeadProvider {
public function getById(int $id): \Bitrix\Crm\Item {
$lead = \Bitrix\Crm\LeadTable::getByPrimary($id)->fetchObject();
if (!$lead) {
throw new \Bitrix\Main\ObjectNotFoundException("Лид #{$id} не найден");
}
return $lead;
}
}
```
### Закон Деметры — не разговаривай с незнакомцами
**ПЛОХО — ЦЕПОЧКА ЧЕРЕЗ ГРАНИЦЫ ОБЪЕКТОВ**
```php
$city = $deal->getContact()->getCompany()->getAddress()->getCity();
```
**ХОРОШО — ДЕЛЕГИРУЮЩИЙ МЕТОД В DEAL**
```php
// Вызов:
$city = $deal->getContactCompanyCity();
// Реализация внутри класса Deal:
public function getContactCompanyCity(): ?string {
return $this->getContact()?->getCompany()?->getCity();
}
```
### KISS — простое решение лучше сложного
Не усложняйте без необходимости. Если задачу можно решить тремя строками — не пишите тридцать.
### YAGNI — не добавляйте функциональность «на будущее»
Реализуйте только то, что требуется прямо сейчас. Код, написанный «на перспективу», часто не используется и засоряет проект.
---
## РАЗДЕЛ 15. События Bitrix (EventManager)
События — ключевой механизм расширения Битрикс без изменения ядра. Тонкие подписчики и делегирование в сервис — обязательный стандарт.
### Тонкий обработчик — логика в сервисе
**ПЛОХО — ВСЯ ЛОГИКА В ТЕЛЕ ОБРАБОТЧИКА**
```php
AddEventHandler('crm', 'OnAfterCrmLeadAdd', function(array &$fields): void {
// 50+ строк: отправка писем, запись в лог, вызов внешних API...
$mailer = new \PHPMailer\PHPMailer\PHPMailer();
$mailer->setFrom(['no-reply@example.com']);
$mailer->addAddress($fields['EMAIL']);
// ...
});
```
**ХОРОШО — ПОДПИСЧИК ДЕЛЕГИРУЕТ В СЕРВИС**
```php
class LeadEventSubscriber {
/** Обработчик намеренно тонкий — вся логика в сервисе. */
public static function onAfterLeadAdd(array &$fields): void {
\Bitrix\Main\DI\ServiceLocator::getInstance()
->get(LeadNotificationService::class)
->notifyOnLeadCreated((int)$fields['ID']);
}
}
// Регистрация в init.php:
\Bitrix\Main\EventManager::getInstance()->addEventHandler(
'crm',
'OnAfterCrmLeadAdd',
[LeadEventSubscriber::class, 'onAfterLeadAdd'],
);
```
### Современный API событий (Bitrix\Main\Event)
**ОБЪЕКТНО-ОРИЕНТИРОВАННЫЙ EVENT API**
```php
\Bitrix\Main\EventManager::getInstance()->addEventHandlerCompatible(
'crm',
'\Bitrix\Crm\Service\Operation\Add::onAfterRun',
static function (\Bitrix\Main\Event $event): \Bitrix\Main\EventResult {
$lead = $event->getParameter('ITEM');
if ($lead instanceof \Bitrix\Crm\Item\Lead) {
\Bitrix\Main\DI\ServiceLocator::getInstance()
->get(LeadNotificationService::class)
->notifyOnLeadCreated($lead->getId());
}
return new \Bitrix\Main\EventResult(\Bitrix\Main\EventResult::SUCCESS);
}
);
```
---
## РАЗДЕЛ 16. Логирование (PSR-3)
Инжектируйте PSR-3 LoggerInterface. Глобальные функции `AddMessage2Log()` — устаревший подход без уровней и контекста.
### PSR-3 логирование через Bitrix\Main\Diag\Logger
**ПЛОХО — УСТАРЕВШИЕ ФУНКЦИИ БЕЗ УРОВНЕЙ**
```php
AddMessage2Log('Ошибка создания лида: ' . $message, 'my_module');
// нет уровней severity, нет структурированного контекста, сложно искать
```
**ХОРОШО — PSR-3 С УРОВНЯМИ И КОНТЕКСТОМ**
```php
$logger->info('Lead created', ['leadId' => $lead->getId(), 'userId' => \CUser::GetID()]);
$logger->error('Lead creation failed', [
'message' => $e->getMessage(),
'code' => $e->getCode(),
'trace' => $e->getTraceAsString(),
]);
```
### Инжектируйте LoggerInterface, а не конкретную реализацию
**ПЛОХО — КОНКРЕТНАЯ РЕАЛИЗАЦИЯ В КОНСТРУКТОРЕ**
```php
class LeadCreationService {
private \Bitrix\Main\Diag\Logger $logger;
public function __construct() {
// нельзя подменить в тестах, привязка к реализации
$this->logger = \Bitrix\Main\Diag\Logger::create('crm', '/local/logs/crm.log');
}
}
```
**ХОРОШО — PSR-3 ИНТЕРФЕЙС ЧЕРЕЗ DI**
```php
use Psr\Log\LoggerInterface;
class LeadCreationService {
public function __construct(
private \Bitrix\Crm\Service\Factory $factory,
private LoggerInterface $logger,
) {}
public function create(CreateLeadDto $dto): \Bitrix\Main\Result {
$this->logger->debug('Creating lead', ['title' => $dto->title]);
// ...
}
}
// Регистрация в ServiceLocator:
ServiceLocator::getInstance()->addInstanceLazy(
LoggerInterface::class,
fn() => \Bitrix\Main\Diag\Logger::create(
'app',
'/local/logs/app_' . date('Y-m-d') . '.log'
)
);
```
---
## РАЗДЕЛ 17. Агенты — AbstractAgent (полный паттерн)
В проекте используется абстрактный базовый класс **AbstractAgent**, который инкапсулирует всю инфраструктуру агентов: регистрацию, запуск, обработку ошибок и повторный вызов. Конкретные агенты реализуют только метод `execute()`.
### Архитектура: AbstractAgent
Базовый класс решает все инфраструктурные задачи — конкретный агент содержит только бизнес-логику.
**ABSTRACTAGENT — БАЗОВЫЙ КЛАСС (APP\AGENTS)**
```php
<?php
declare(strict_types=1);
namespace App\Agents;
use App\Logger;
use Bitrix\Main\SystemException;
use Bitrix\Main\Type\DateTime;
use CAgent;
use DateTimeZone;
use Exception;
use Psr\Log\LoggerInterface;
use RuntimeException;
/**
* Абстрактный класс для создания агентов Битрикс24.
* Конкретный агент обязан реализовать только метод execute().
*/
abstract class AbstractAgent
{
protected const DEFAULT_PRIORITY = 100;
protected const DEFAULT_INTERVAL = 86400; // 24 часа
protected const MODULE_ID = 'main';
protected const EXEC_DATETIME_HOUR = 4;
protected const EXEC_DATETIME_MINUTE = 0;
protected const IS_PERIODICAL_AGENT = 'Y';
protected LoggerInterface $logger;
/**
* Выполнение основной логики агента.
* Должен вернуть строку следующего вызова.
*
* @throws Exception
*/
abstract public function execute(): string;
/** Интервал выполнения в секундах (можно переопределить). */
protected function getInterval(): int {
return static::DEFAULT_INTERVAL;
}
/** Приоритет агента. */
protected function getPriority(): int {
return static::DEFAULT_PRIORITY;
}
/** Модуль, к которому привязан агент. */
protected function getModuleId(): string {
return static::MODULE_ID;
}
/** Тип агента (периодический / разовый). */
protected function getType(): string {
return static::IS_PERIODICAL_AGENT;
}
/** Время первого запуска агента (по московскому времени). */
protected function getExecDateTime(): int
{
$dateTime = new DateTime();
$dateTime->setTimeZone(new DateTimeZone('Europe/Moscow'));
return $dateTime
->setTime(static::EXEC_DATETIME_HOUR, static::EXEC_DATETIME_MINUTE)
->getTimestamp();
}
/**
* Проверка предусловий.
* Переопределите в конкретном агенте при необходимости.
*
* @throws RuntimeException
*/
protected function checkConditions(): void {}
/** Логирование ошибки выполнения. */
protected function handleError(Exception $e): void
{
$this->logger = Logger::getInstance(module: $this->getModuleId());
$this->logger->error($e->getMessage(), $e->getTrace());
}
/** Строка повторного вызова агента (регистрируется в Битрикс). */
protected function createNextCall(): string
{
return static::class . '::agent();';
}
/**
* Статический точка входа агента — именно эта строка регистрируется в CAgent.
* Не содержит бизнес-логики: только запуск и перехват исключений.
*/
public static function agent(): string
{
$instance = new static();
try {
$instance->checkConditions();
return $instance->execute();
} catch (Exception $e) {
$instance->handleError($e);
return $instance->createNextCall();
}
}
/**
* Регистрация агента в системе Битрикс.
* Вызывается при установке модуля (DoInstall).
*
* @throws SystemException
*/
public static function register(): bool
{
$instance = new static();
$execTime = $instance->getExecDateTime();
static::unregister(); // удаляем дубли
$agentId = CAgent::AddAgent(
$instance->createNextCall(),
$instance->getModuleId(),
$instance->getType(),
$instance->getInterval(),
ConvertTimeStamp($execTime, 'FULL'),
'Y',
ConvertTimeStamp($execTime, 'FULL'),
$instance->getPriority(),
);
if (!$agentId) {
throw new SystemException(
'Ошибка регистрации агента: ' . static::class
);
}
return true;
}
/** Удаление агента из системы (вызывается при деинсталляции). */
public static function unregister(): void
{
$instance = new static();
CAgent::RemoveAgent($instance->createNextCall(), $instance->getModuleId());
}
/** Проверка, зарегистрирован ли агент. */
public static function isRegistered(): bool
{
$instance = new static();
$agent = CAgent::GetList(
[],
['NAME' => $instance->createNextCall(), 'MODULE_ID' => $instance->getModuleId()]
)->Fetch();
return $agent !== false;
}
}
```
### Пример конкретного агента
Конкретный агент наследует AbstractAgent и реализует только метод `execute()`. Вся инфраструктура — регистрация, перехват ошибок, повторный вызов — унаследована.
**ПЛОХО — ПРОЦЕДУРНЫЙ АГЕНТ, ВСЯ ЛОГИКА В ОДНОЙ ФУНКЦИИ**
```php
// agents.php в модуле
function DaSkud_ImportAgent(): string {
// 80 строк: создание клиентов, запросы к API, запись в БД...
$skudClient = new SkudV2Client(/* параметры хардкодом */);
$end = new DateTime();
$start = (clone $end)->modify('-1 day');
$res = $skudClient->getAttendance($start->format('Y-m-d'), $end->format('Y-m-d'));
foreach ($res as $record) {
\CIBlockElement::Add([/* ... */]);
}
return __FUNCTION__ . '();';
}
```
**ХОРОШО — КОНКРЕТНЫЙ АГЕНТ С МИНИМАЛЬНОЙ РЕАЛИЗАЦИЕЙ**
```php
<?php
declare(strict_types=1);
namespace Da\Skud\Agents;
use App\Agents\AbstractAgent;
use Bitrix\Main\DI\ServiceLocator;
use Da\Skud\ImportCommand;
use Da\Skud\IntegrationInvoker;
use Da\Skud\Services\BitrixService;
use Da\Skud\Services\SkudV2Service;
use Da\Skud\SkudV2Client;
use DateTime;
class SkudV2Agent extends AbstractAgent
{
// Переопределяем MODULE_ID для привязки к конкретному модулю
public const MODULE_ID = 'da.skud';
/**
* Вся бизнес-логика инкапсулирована в сервисах.
* Агент только оркестрирует вызов.
*/
public function execute(): string
{
/** @var SkudV2Client $skudClient */
$skudClient = ServiceLocator::getInstance()->get('da.skud.client');
$end = new DateTime();
$start = (clone $end)->modify('-1 day');
$skudService = new SkudV2Service(
$skudClient,
$start->format('Y-m-d'),
$end->format('Y-m-d'),
);
$bitrixService = new BitrixService($start->format('Y-m-d'));
$command = new ImportCommand($skudService, $bitrixService);
$invoker = new IntegrationInvoker();
$invoker->submit($command);
return $this->createNextCall(); // ОБЯЗАТЕЛЬНО
}
}
```
### Регистрация и управление агентом
**ПЛОХО — РУЧНАЯ РЕГИСТРАЦИЯ С ДУБЛИРОВАНИЕМ ПАРАМЕТРОВ**
```php
// В DoInstall() — параметры хардкодом, легко ошибиться
\CAgent::AddAgent(
'DaSkud_ImportAgent();',
'da.skud',
'Y',
86400,
'',
'Y',
\ConvertTimeStamp(time() + 86400, 'FULL'),
100
);
```
**ХОРОШО — ЧЕРЕЗ ABSTRACTAGENT::REGISTER()**
```php
// install/index.php — метод DoInstall():
use Da\Skud\Agents\SkudV2Agent;
// Все параметры определены константами в классе агента
SkudV2Agent::register();
// Проверка перед регистрацией:
if (!SkudV2Agent::isRegistered()) {
SkudV2Agent::register();
}
// install/index.php — метод DoUninstall():
SkudV2Agent::unregister();
```
### Обработка исключений в агентах — критическое правило
**ВАЖНО:** Агент НЕ должен пробрасывать исключение наружу. Если `execute()` бросит необработанное исключение, Битрикс удалит агент из очереди и он перестанет выполняться. AbstractAgent перехватывает все исключения в методе `agent()` и логирует их — конкретный агент должен либо обрабатывать их внутри `execute()`, либо позволить AbstractAgent сделать это.
**КАК ABSTRACTAGENT ЗАЩИЩАЕТ ОЧЕРЕДЬ АГЕНТОВ**
```php
// AbstractAgent::agent() — точка входа:
public static function agent(): string
{
$instance = new static();
try {
$instance->checkConditions();
return $instance->execute();
} catch (Exception $e) {
$instance->handleError($e);
return $instance->createNextCall(); // НЕ удаляем агент из очереди
}
}
```