ГлавнаяБлогErrorCollection в D7-контроллерах: коды ошибок вместо success/error

ErrorCollection в D7-контроллерах: коды ошибок вместо success/error

Рамиль Юналиев
Рамиль Юналиев
E-Commerce Lead
23 июля 2026 г.
5 мин чтения

В посте про AJAX action просто возвращал массив, а в посте про CSRF на ошибку invalid_csrf уже смотрели с фронта через response.errors. Но там это была ошибка от фильтра — платформа собрала её сама. Если ошибку в action нужно вернуть из своего кода (не прошла валидация, не найдена запись, нет прав на конкретное действие), возникает соблазн просто вернуть ['error' => 'Нет прав'] и разбирать текст на фронте. Работает, но не так, как задумано в D7 — для этого есть ErrorCollection.

Чем плох голый error в ответе

public function deleteAction(int $id): array
{
    if (!$this->hasAccess($id)) {
        return ['error' => 'Нет прав на удаление'];
    }
    // ...
    return ['result' => true];
}

Проблема не в том, что это не работает — проблема в статусе ответа. Action вернул обычный массив. Для платформы это успешный ответ, поэтому response.status остаётся success. BX.ajax.runComponentAction смотрит именно на этот статус: success уводит промис в resolve, всё остальное — в reject. В итоге код с ошибкой прилетает в ветку then, а не catch — клиентскому коду приходится самому заглядывать в данные и решать, не ошибка ли это, вместо того чтобы просто отловить reject. Плюс текст ошибки жёстко зашит в код — сравнивать строки, чтобы отличить «нет прав» от «не найдено», ненадёжно и ломается при любой правке текста.

Разделение статуса и содержимого ответа — та же идея, что в HTTP: успех и ошибка разъезжаются на уровне структуры ответа, а не прячутся внутри одинаковых по форме данных. Клиентскому коду не нужно разбирать содержимое каждого ответа вручную — достаточно одной проверки статуса, а обработку ошибок можно вынести в одно место вместо каждого .then() по отдельности.

ErrorCollection на компоненте

Чтобы ошибки участвовали в статусе ответа, компонент должен реализовать интерфейс \Bitrix\Main\Errorable (помимо уже знакомого Controllerable из поста про AJAX) и завести ErrorCollection в onPrepareComponentParams() — он выполняется и при обычном рендере, и при ajax-вызове, в отличие от executeComponent():

<?php
use Bitrix\Main\Error;
use Bitrix\Main\ErrorCollection;
use Bitrix\Main\Errorable;
use Bitrix\Main\Engine\Contract\Controllerable;
 
class ExampleComponent extends \CBitrixComponent implements Controllerable, Errorable
{
    protected ErrorCollection $errorCollection;
 
    public function onPrepareComponentParams($arParams)
    {
        $this->errorCollection = new ErrorCollection();
        return $arParams;
    }
 
    public function configureActions(): array
    {
        return []; // фильтры вроде Csrf из прошлого поста опущены для краткости примера
    }
 
    public function getErrors(): array
    {
        return $this->errorCollection->toArray();
    }
 
    public function getErrorByCode($code)
    {
        return $this->errorCollection->getErrorByCode($code);
    }
}

getErrors() и getErrorByCode() — это как раз то, что требует Errorable; без них интерфейс не реализован, а без ErrorCollection, заведённой в onPrepareComponentParams(), обращение к $this->errorCollection в action упадёт с ошибкой на пустом свойстве.

addError в action

ErrorCollection реализует \ArrayAccess, поэтому добавить ошибку можно просто через [], без отдельного метода. Никакой магии в этом нет — [] здесь работает ровно потому, что класс реализует стандартный интерфейс PHP, а не потому, что ErrorCollection как-то по-особенному ведёт себя внутри D7:

public function deleteAction(int $id): ?array
{
    if (!$this->hasAccess($id)) {
        $this->errorCollection[] = new Error('Нет прав на удаление', 'access_denied', [
            'entityId' => $id,
            'permission' => 'delete',
        ]);
        return null;
    }
    // ...
}

Конструктор Error__construct($message, $code = 0, $customData = null). Ошибку не нужно ещё и возвращать из action отдельно — за счёт интерфейса Errorable платформа сама вызывает getErrors() компонента и, если коллекция не пуста, переводит статус ответа в error и кладёт её содержимое в response.errors. Это никак не связано с тем, что именно возвращает action — return null в примере выше просто честно показывает, что при ошибке никаких данных для клиента нет, а не требование framework.

Так выглядит успешный ответ:

{
  "status": "success",
  "data": { "result": true },
  "errors": []
}

А так — ответ с ошибкой: по документации BX.ajax.runComponentAction в таком ответе ключа data нет, только status и errors с кодом и customData:

{
  "status": "error",
  "errors": [
    {
      "message": "Нет прав на удаление",
      "code": "access_denied",
      "customData": { "entityId": 42, "permission": "delete" }
    }
  ]
}

customData в этом примере — не поле формы, а данные о самой операции: с ними клиентский код может залогировать, к какой записи и какому праву относится отказ, без парсинга текста сообщения.

Несколько ошибок сразу — валидация формы

В отличие от ['error' => 'текст'], ErrorCollection — коллекция, а не одно значение, поэтому под неё естественно ложится валидация с несколькими полями сразу, а не только первая найденная ошибка:

public function sendAction(string $email = '', string $message = ''): ?array
{
    if ($email === '' || !check_email($email)) {
        $this->errorCollection[] = new Error('Некорректный email', 'invalid_email', ['field' => 'email']);
    }
    if ($message === '') {
        $this->errorCollection[] = new Error('Сообщение не может быть пустым', 'empty_message', ['field' => 'message']);
    }
 
    if (!$this->errorCollection->isEmpty()) {
        return null;
    }
 
    // ...
}

Третий аргумент ErrorcustomData — здесь несёт имя поля: без него на фронте пришлось бы искать нужное поле по тексту сообщения, с ним error.customData.field сразу говорит, у какого input подсвечивать рамку.

На фронте — код и customData вместо текста

BX.ajax.runComponentAction('vendor:example', 'send', {
  mode: 'class',
  data: { email, message },
}).then(function (response) {
  console.log(response.data);
}, function (response) {
  response.errors.forEach(function (error) {
    if (error.code === 'invalid_email') {
      showFieldError('email', error.message);
    } else if (error.customData && error.customData.field) {
      showFieldError(error.customData.field, error.message);
    }
  });
});

Ветвление идёт по error.code, а не по error.message — текст можно свободно менять или переводить, код и структура customData при этом не трогаются, и фронт не ломается от правки текста ошибки.

ErrorCollection или просто throw

У ErrorCollection есть более короткая альтернатива — выбросить исключение (\Bitrix\Main\ArgumentException, \Bitrix\Main\SystemException) прямо из action. Controller, через который выполняются и Controllerable-компоненты, сам ловит исключение и превращает его в такой же error-ответ, без addError() в самом action. Разница по существу: ErrorCollection — это когда ошибок несколько и по каждой нужен свой код/customData (форма с несколькими полями из примера выше), throw — когда ошибка одна и фатальная, а детализация по полям не нужна.

Когда голого массива достаточно

Критерий — не в том, чтение это или изменение данных, а в том, есть ли у action бизнес-ошибка. Пустой список по фильтру, ноль в счётчике — нормальный результат, не ошибка; под него ErrorCollection не нужен, обычный return ['items' => []] читается ничуть не хуже. А вот «нет доступа», «запись не найдена», «некорректный ID» — это бизнес-ошибка независимо от того, GET это или POST по смыслу, и разбирать её текстом на клиенте так же неудобно, как в примере из начала поста. ErrorCollection окупается там, где клиенту нужно программно отличать один код ошибки от другого — а не просто показать текст в алерте.