Привет, хабровчане! На связи Алиса — тимлид в e-commerce агентстве KISLOROD.

Кто о чем, а я продолжаю рассказывать, как сшипперить Bitrix и Laravel. В первой части я рассказывала, как подружить Laravel с Битриксом так, чтобы никто не пострадал. Во второй — как устроить единый вход без шаринга сессий, ускорить каталог с OpenSearch, внедрить outbox-публикации и навести порядок в наблюдаемости. Теперь третий шаг — разгружаем чтение.

Каталог в Битриксе — система, проверенная временем. Она хорошо справляется с хранением и администрированием, но если нагрузить ее фильтрами, фасетами и сортировками, начинаются задержки, лишние запросы, теряется плавность работы. Мы решили не перегружать основную систему, а освободить ее от тяжелого чтения: вынесли все в Laravel и OpenSearch. Битрикс продолжает делать то, что умеет лучше всего, а быстрые ответы теперь приходят оттуда, где для этого все подготовлено.

Как это работает

Путь данных устроен просто и надежно:

  1. Контентщик нажимает «Сохранить» в Битриксе — привычный процесс не меняется.

  2. Событие попадает в outbox, где фиксируется и ждет своего часа, даже если очередь временно недоступна.

  3. Через Redis Streams событие уходит в Laravel — быстро и без лишних зависимостей.

  4. Консюмер обновляет витрины в svc_catalog_*, готовит данные для поиска.

  5. Документ индексируется в OpenSearch и становится доступен для запроса.

С этого момента все, что связано с отображением каталога — сайт, внутренние панели, партнерские интерфейсы — запрашивает данные у Laravel API.

Если вдруг OpenSearch временно недоступен, Laravel не делает драму из ситуации. Он возвращает предсказуемый результат из Redis или аккуратную заглушку — страницу можно открыть, список не пустой, пользователь не теряется.

А в это время система уже сообщает, что что-то идет не по плану: срабатывают алерты, в логах видны детали, а в дашборде — четкий сигнал, где и когда началось замедление. Никаких асапов в чате, работаем не постфактум, а по делу.

Что именно выносим и зачем

Битрикс отлично справляется с ролью редактора. Но его модель хранения — это набор таблиц и связей, которые для чтения хороши только в теории. А на практике каждый фильтр превращается в SQL-квест с множеством джойнов и подзапросов.

Поэтому в read-модели мы идём по другому пути: 

  • складываем нужные поля в плоскую структуру;

  • приводим типы к единому виду — чтобы можно было сразу фильтровать и агрегировать;

  • текстовые значения дублируем, где нужно — для поиска и сортировки;

  • свойства и остатки выносим как nested-объекты;

  • фасеты и агрегаты считаем заранее, при записи.

Скрытый текст

Ставим официальный PHP-клиент OpenSearch или используем HTTP-запросы. Ниже пример маппинга с полями под полнотекст, фильтры и фасеты.

{
  "settings": {
    "number_of_shards": 3,
    "number_of_replicas": 1,
    "index": {
      "refresh_interval": "1s"
    },
    "analysis": {
      "analyzer": {
        "ru_text": {
          "tokenizer": "standard",
          "filter": ["lowercase", "russian_stop", "russian_stemmer"]
        }
      },
      "filter": {
        "russian_stop": { "type": "stop", "stopwords": "_russian_" },
        "russian_stemmer": { "type": "stemmer", "language": "russian" }
      }
    }
  },
  "mappings": {
    "properties": {
      "id":            { "type": "keyword" },
      "sku":           { "type": "keyword" },
      "title":         { "type": "text", "analyzer": "ru_text", "fields": { "raw": { "type": "keyword" } } },
      "brand":         { "type": "keyword" },
      "category":      { "type": "keyword" },
      "price":         { "type": "double" },
      "price_type":    { "type": "keyword" },
      "in_stock":      { "type": "boolean" },
      "warehouses":    { "type": "nested", "properties": {
        "id":        { "type": "keyword" },
        "qty":       { "type": "double" }
      }},
      "attrs":         { "type": "nested", "properties": {
        "code":      { "type": "keyword" },
        "value_str": { "type": "keyword" },
        "value_num": { "type": "double" }
      }},
      "created_at":    { "type": "date" },
      "updated_at":    { "type": "date" }
    }
  }
}

В индекс кладем только то, что нужно для поиска, фильтрации и отображения в листинге. А полную карточку собираем отдельно — уже из витрины.

Как устроен индекс

Структура у нас максимально утилитарная:

  • keyword, boolean и double — для фильтрации;

  • text с анализатором — для поиска по названию;

  • title.raw — для сортировки по алфавиту;

  • attrs и warehouses — как nested, чтобы обрабатывать условия типа «цвет = красный и размер = L» внутри одного товара;

  • updated_at — чтобы отслеживать свежесть данных.

Это позволяет OpenSearch быстро отвечать даже на сложные запросы — с текстом, фильтрами, сортировкой и фасетами.

Laravel наносит ответный удар

Поиск и карточки товаров теперь живут в Laravel. Один контроллер отвечает за оба сценария: ищет и показывает. Всё просто — валидируем запрос, строим DSL для OpenSearch, быстро кешируем. Никаких тяжёлых фильтров в Битриксе, никаких задержек.

Если вам нужен продовый уровень: добавьте поддержку ETag, If-None-Match и троттлинг по ключам партнёров. Но даже без этого API выдаёт стабильный ответ с фильтрами, фасетами и агрегациями — фронту только отрисовать.

А для карточки: Laravel собирает полную информацию из svc_catalog_* и возвращает JSON. Если товар не найден — отдаём 404, если найден — отдаем готовый к рендеру объект.

Обновление индекса — через события. Как только товар меняется:

  1. Событие попадает в outbox.

  2. Laravel консюмер получает его через Streams.

  3. Собирает свежую карточку из витрин svc_catalog_*.

  4. Формирует документ.

  5. Обновляет его в OpenSearch.

  6. Сбивает кеш — чтобы все пересчитать на фронте.

Если товар удален — просто удаляем документ из индекса. 

Скрытый текст

Сначала поставим клиент и опишем обертку.

composer require opensearch-project/opensearch-php:^2.2

Сервис работы с индексом:

<?php
namespace App\Catalog;

use OpenSearch\Client;
use OpenSearch\ClientBuilder;

final class SearchIndex
{
    private Client $client;
    private string $index;

    public function __construct(?Client $client = null, ?string $index = null)
    {
        $this->client = $client ?? ClientBuilder::create()->setHosts([env('OS_HOST', 'http://localhost:9200')])->build();
        $this->index  = $index ?? env('OS_INDEX', 'catalog_v1');
    }

    public function upsert(array $doc): void
    {
        $this->client->index([
            'index' => $this->index,
            'id'    => (string) $doc['id'],
            'body'  => $doc,
            'refresh' => false,
        ]);
    }

    public function delete(int|string $id): void
    {
        $this->client->delete(['index' => $this->index, 'id' => (string) $id]);
    }

    public function search(array $dsl): array
    {
        $res = $this->client->search(['index' => $this->index, 'body' => $dsl]);
        return $res['hits'] ?? [];
    }

    public function dsl(string $q, array $filters, array $facets, int $page, int $perPage): array
    {
        $must   = [];
        $filter = [];

        if ($q !== '') {
            $must[] = ['multi_match' => [
                'query' => $q,
                'fields' => ['title^2', 'title.raw', 'brand', 'sku'],
                'type' => 'best_fields'
            ]];
        }

        // простые фильтры: brand, category, price ranges
        foreach (['brand', 'category', 'price_type'] as $f) {
            if (! empty($filters[$f])) {
                $filter[] = ['terms' => [$f => (array) $filters[$f]]];
            }
        }
        if (! empty($filters['price_min']) || ! empty($filters['price_max'])) {
            $range = [];
            if (isset($filters['price_min'])) $range['gte'] = (float) $filters['price_min'];
            if (isset($filters['price_max'])) $range['lte'] = (float) $filters['price_max'];
            $filter[] = ['range' => ['price' => $range]];
        }

        // nested-атрибуты: attrs.code=value_str
        if (! empty($filters['attrs']) && is_array($filters['attrs'])) {
            foreach ($filters['attrs'] as $code => $vals) {
                $filter[] = [
                    'nested' => [
                        'path'  => 'attrs',
                        'query' => [
                            'bool' => [
                                'must' => [
                                    ['term' => ['attrs.code' => $code]],
                                    ['terms' => ['attrs.value_str' => (array) $vals]]
                                ]
                            ]
                        ]
                    ]
                ];
            }
        }

        $aggs = [];
        foreach ($facets as $name) {
            $aggs[$name] = match ($name) {
                'brand'     => ['terms' => ['field' => 'brand', 'size' => 50]],
                'category'  => ['terms' => ['field' => 'category', 'size' => 50]],
                'price'     => ['histogram' => ['field' => 'price', 'interval' => 500]],
                default     => null,
            };
        }
        $aggs = array_filter($aggs);

        return [
            'from' => max(0, ($page - 1) * $perPage),
            'size' => $perPage,
            'sort' => [['title.raw' => 'asc']],
            'query' => [
                'bool' => [
                    'must'   => $must,
                    'filter' => $filter,
                ]
            ],
            'aggs'  => $aggs,
        ];
    }
}

Мы уже сделали общую консольную команду чтения Streams. Теперь добавим конкретный job, который собирает документ для индекса

<?php

namespace App\Jobs;

use App\Catalog\SearchIndex;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

final class ReindexProduct implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries   = 5;
    public int $backoff = 10;

    public function __construct(private readonly string $payloadJson)
    {
    }

    public function handle(SearchIndex $index): void
    {
        $payload = \json_decode($this->payloadJson, true, flags: \JSON_THROW_ON_ERROR);
        $id      = (int) $payload['id'];

        // тянем свежие данные из витрин svc_catalog_*
        $row = \DB::table('svc_catalog_product')
            ->select([
                'id', 'sku', 'title', 'brand', 'category',
                'price', 'price_type', 'in_stock', 'updated_at',
            ])
            ->where('id', $id)
            ->first();

        if ($row === null) {
            $index->delete($id);
            return;
        }

        $attrs = \DB::table('svc_catalog_attrs')
            ->where('product_id', $id)
            ->get(['code', 'value_str', 'value_num'])
            ->map(fn($a) => [
                'code' => (string) $a->code,
                'value_str' => $a->value_str ? (string) $a->value_str : null,
                'value_num' => $a->value_num ? (float) $a->value_num : null,
            ])->all();

        $wh = \DB::table('svc_inventory')
            ->where('product_id', $id)
            ->get(['warehouse_id as id', 'qty'])
            ->map(fn($w) => ['id' => (string) $w->id, 'qty' => (float) $w->qty])
            ->all();

        $doc = [
            'id' => (string) $row->id,
            'sku' => (string) $row->sku,
            'title' => (string) $row->title,
            'brand' => (string) $row->brand,
            'category' => (string) $row->category,
            'price' => (float)  $row->price,
            'price_type' => (string) $row->price_type,
            'in_stock' => (bool)   $row->in_stock,
            'attrs' => \array_values($attrs),
            'warehouses' => \array_values($wh),
            'updated_at' => (string) $row->updated_at,
        ];

        $index->upsert($doc);

        // инвалидация кэшей карточки и листингов
        \Cache::tags(['product', 'product:'.$id])->flush();
    }
}

Контроллер делает две вещи: валидирует вход, строит DSL и кеширует ответ на короткое время. В проде добавьте ETag/If-None-Match и лимиты

<?php
namespace App\Http\Controllers;

use App\Catalog\SearchIndex;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;

final class CatalogController extends Controller
{
    public function search(Request $request, SearchIndex $index): JsonResponse
    {
        $data = $request->validate([
            'q'        => ['nullable', 'string', 'max:128'],
            'brand'    => ['array'],
            'brand.*'  => ['string', 'max:64'],
           'category' => ['array'],
            'category.*' => ['string', 'max:64'],
            'price_min'  => ['nullable', 'numeric', 'min:0'],
            'price_max'  => ['nullable', 'numeric', 'min:0'],
            'page'       => ['nullable', 'integer', 'min:1'],
            'per_page'   => ['nullable', 'integer', 'min:1', 'max:60'],
            'attrs'      => ['array'],
        ]);

        $page    = (int) ($data['page'] ?? 1);
        $perPage = (int) ($data['per_page'] ?? 24);
        $q       = (string) ($data['q'] ?? '');

        $filters = \Arr::only($data, ['brand', 'category', 'price_min', 'price_max', 'price_type', 'attrs']);
        $facets  = ['brand', 'category', 'price'];

        $cacheKey = 'search:' . \md5(\json_encode([$q, $filters, $page, $perPage]));
        $result = Cache::remember($cacheKey, 10, function () use ($index, $q, $filters, $facets, $page, $perPage) {
            $dsl = $index->dsl($q, $filters, $facets, $page, $perPage);
            return $index->search($dsl);
        });

        return response()->json([
            'ok'    => true,
           'hits'  => $result['hits'] ?? [],
            'total' => $result['total']['value'] ?? 0,
            'page'  => $page,
            'per'   => $perPage,
            'took'  => $result['took'] ?? null,
            'aggs'  => $result['aggregations'] ?? new \stdClass(),
        ]);
    }

    public function show(int $id): JsonResponse
    {
        $data = Cache::remember("product:$id", 30, function () use ($id) {
            $card = \DB::table('svc_catalog_product')->where('id', $id)->first();
            if (! $card) {
                return null;
            }
            $attrs = \DB::table('svc_catalog_attrs')->where('product_id', $id)->get();
            $wh    = \DB::table('svc_inventory')->where('product_id', $id)->get();

            return [
                'product' => $card,
                'attrs'   => $attrs,
                'stock'   => $wh,
            ];
        });

        if ($data === null) {
            return response()->json(['ok' => false, 'error' => 'not_found'], 404);
        }

        return response()->json(['ok' => true, 'data' => $data]);
    }
}

Маршруты:

<?php

use App\Http\Controllers\CatalogController;
use Illuminate\Support\Facades\Route;

Route::prefix('api/v1')->group(function () {
    Route::get('/catalog/search', [CatalogController::class, 'search']);
    Route::get('/products/{id}',  [CatalogController::class, 'show'])->whereNumber('id');
});

У нас уже есть команда, которая читает Redis Streams и распределяет события. Теперь добавляем к ней конкретную задачу — обновить витрину товара и отправить его в индекс.

Как это работает:

  1. Из события берём id товара.

  2. Тянем свежие данные из витрины svc_catalog_product.

  3. Если товара больше нет — удаляем из индекса.

  4. Иначе собираем все нужные куски: свойства, остатки, мета.

  5. Собираем документ — и в upsert.

  6. Не забываем: сбрасываем кэш карточки и листинга, чтобы все обновилось.

Получается, одно событие — один индексированный документ. Пришло — обработали, обновили, отдали. Если что-то пошло не так, Laravel сам повторит попытку. 

Скрытый текст
<?php

namespace App\Jobs;

use App\Catalog\SearchIndex;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

final class ReindexProduct implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries   = 5;
    public int $backoff = 10;

    public function __construct(private readonly string $payloadJson)
    {
    }

    public function handle(SearchIndex $index): void
    {
        $payload = \json_decode($this->payloadJson, true, flags: \JSON_THROW_ON_ERROR);
        $id      = (int) $payload['id'];

        // 1) тянем свежие данные из витрин svc_catalog_*
        $row = \DB::table('svc_catalog_product')
            ->select([
                'id', 'sku', 'title', 'brand', 'category',
                'price', 'price_type', 'in_stock', 'updated_at',
            ])
            ->where('id', $id)
            ->first();
       if ($row === null) {
            $index->delete($id);
            return;
        }

        $attrs = \DB::table('svc_catalog_attrs')
            ->where('product_id', $id)
            ->get(['code', 'value_str', 'value_num'])
            ->map(fn($a) => [
                'code'      => (string) $a->code,
                'value_str' => $a->value_str ? (string) $a->value_str : null,
                'value_num' => $a->value_num ? (float) $a->value_num : null,
            ])->all();

        $wh = \DB::table('svc_inventory')
            ->where('product_id', $id)
            ->get(['warehouse_id as id', 'qty'])
            ->map(fn($w) => ['id' => (string) $w->id, 'qty' => (float) $w->qty])
            ->all();

        $doc = [
            'id'         => (string) $row->id,
            'sku'        => (string) $row->sku,
            'title'      => (string) $row->title,
            'brand'      => (string) $row->brand,
            'category'   => (string) $row->category,
            'price'      => (float)  $row->price,
            'price_type' => (string) $row->price_type,
            'in_stock'   => (bool)   $row->in_stock,
            'attrs'      => \array_values($attrs),
            'warehouses' => \array_values($wh),
            'updated_at' => (string) $row->updated_at,
        ];

        $index->upsert($doc);

        // инвалидация кэшей карточки и листингов
        \Cache::tags(['product', 'product:'.$id])->flush();
    }
}

Публичные страницы больше не вызывают тяжелые компоненты catalog.section. Вместо этого запрос в Laravel API через легкий обертку-клиент.

Если вы используете свои шаблоны, достаточно изменить источник данных. Если фронт выделен, он с самого начала работает через API.

Для админки все осталось по-прежнему. Кнопка «Сохранить» работает моментально, потому что индексация и сборка карточек идут в фоне, ошибки не блокируют интерфейс, а отказ OpenSearch не приводит к падению страницы, потому что будет безопасный ответ из Redis.

Пример безопасного клиента в Битрикс с короткими таймаутами и корреляцией:

Скрытый текст
<?php

use Bitrix\Main\Web\HttpClient;
use Bitrix\Main\Web\Json;
use Bitrix\Main\Diag\Helper as DiagHelper;

$client = new HttpClient([
    'socketTimeout' => 2,
    'streamTimeout' => 2,
    'waitResponse'  => true,
    'redirect'      => true,
    'retries'       => 1,
]);

$client->setHeader('Content-Type', 'application/json');
$client->setHeader('X-Request-Id', DiagHelper::getRequestId());

$q = ['q' => (string) $_GET['q'], 'page' => (int) ($_GET['p'] ?? 1)];
$client->query('GET', 'https://api.example.ru/api/v1/catalog/search?' . http_build_query($q));

$list = Json::decode($client->getResult() ?: '{"ok":false}');

Fallback и деградация

OpenSearch может притормозить или временно лечь — это нормально. Мы к этому готовы. Laravel не паникует, а спокойно отдает кешированные подборки из Redis: популярное, похожее, хиты продаж. Пользователь ничего не замечает, страница не разваливается. 

Карточки товаров вообще не зависят от поиска: собираются напрямую из svc_catalog_*. А OpenSearch остается для «вкусного» — подсказок, похожих товаров, умных фильтров.

Реплей и миграции

Если вдруг схема изменилась и решили денормализовать по-другому, это не повод для аврала.

Outbox, настроенный ещё на старте, позволяет все переиграть. На проде это удобно запускать батчами командой php artisan catalog:reindex --since=.... Так мы снимаем основной тормоз каталога: Битрикс продолжает владеть записью и админкой, а публичное чтение уезжает в быстрый слой Laravel+OpenSearch.

Админка та же, только быстрее

Когда контентщики жалуются, что «Битрикс тормозит», чаще всего речь не о сохранении. Сама кнопка «Сохранить» работает нормально. Болит другое — открытие карточки. Пока подтянутся справочники, пока загрузятся свойства, пока все это отрендерится — человек уже успел выпить чаю.

Но мы не ломаем интерфейс, не переучиваем команду. Вместо этого — встраиваем тонкую прослойку, которая делает все, как раньше, только быстрее:

  1. Лениво подгружаем вкладки — данные подтягиваются только при первом клике.

  2. Справочники и автокомплиты — переводим на быстрые REST-эндпоинты в Laravel, без лишней нагрузки.

  3. Метаданные и списки — кешируем на несколько минут, чтобы не гонять одно и то же.

Внешне — ничего не поменялось. Контентщик работает в привычной форме. Но «холодный старт» карточки перестает тянуться вечность, а сохранение больше не зависит от ответа внешнего сервиса.

Архитектурно это работает так. В Битриксе мы помечаем «тяжелые» блоки формы — те, что обычно тормозят — плейсхолдерами. Вместо того, чтобы грузить их сразу, подключаем легкий JS, который подгружает содержимое после клика по нужной вкладке. Без запроса нет нагрузки.

Справочники, бренды, города и другие большие списки не загружаем «оптом». Вместо этого используем автокомплиты, которые ходят в быстрый REST на Laravel. Таймауты короткие, а нагрузка минимальная.

Метаданные полей, структуры HL-блоков, подсказки — все это кешируем: в Managed Cache на стороне Битрикса и в Redis на стороне Laravel. Поэтому даже повторные заходы в формы становятся легче.

Наблюдаемость сохраняем: P95 по открытию формы и по «Сохранить» меряется и алертится. Если вдруг где-то что-то поплыло, мы это видим сразу, не дожидаясь гневных сообщений от коллег.

Битрикс: легкая вставка в админку и ленивые вкладки

Подключаем модульный JS только в админке, не трогая публичку. В нем перехватываем клики по вкладкам и подкачиваем контент.

Инициализация модуля и ассетов:

Скрытый текст

Инициализация модуля и ассетов:

<?php

use Bitrix\Main\Context;
use Bitrix\Main\Page\Asset;

AddEventHandler('main', 'OnBeforeProlog', static function (): void {
    if (defined('ADMIN_SECTION') && ADMIN_SECTION === true) {
        Asset::getInstance()->addJs('/local/modules/project.adminaccelerator/assets/admin-boost.js');
        Asset::getInstance()->addCss('/local/modules/project.adminaccelerator/assets/admin-boost.css');
    }
});

Контроллер для частичной подгрузки вкладок:

<?php

namespace Project\AdminAccelerator\Controller;

use Bitrix\Main\Engine\Controller;
use Bitrix\Main\Engine\ActionFilter;
use Bitrix\Main\Loader;
use Bitrix\Main\Localization\Loc;

final class AdminTabController extends Controller
{
    public function configureActions(): array
    {
        return [
            'load' => [
                '+prefilters' => [
                    new ActionFilter\HttpMethod(['GET']),
                    new ActionFilter\Authentication(),
                ],
            ],
        ];
    }

    public function loadAction(int $elementId, string $tabCode): array
    {
        global $USER;
        if (! $USER->IsAdmin() && ! $USER->CanDoOperation('edit_php')) {
            $this->addError(new \Bitrix\Main\Error('Access denied'));
            return ['html' => ''];
        }

        // Здесь рендерится то, что раньше грузилось синхронно:
        // например, длинный список связанных сущностей, логи изменений, историю заказов и т.п.
        ob_start();
        include __DIR__ . '/../views/tabs/' . basename($tabCode) . '.php';
        $html = (string) ob_get_clean();

        return ['html' => $html];
    }
}

Для загрузки «тяжелых» вкладок мы используем Bitrix\Main\Engine\Controller. Он позволяет отдать HTML-фрагмент по запросу — как раз то, что нужно для ленивой загрузки из JS.

Контроллер обязательно проверяет права доступа, чтобы не отдавать лишнего. Регистрируем его стандартно: action=project:adminaccelerator.AdminTab.load. А дальше вызываем из JS, когда пользователь кликает по нужной вкладке.

В результате — та же форма, но открывается она быстро, потому что не тащит за собой все сразу.

Скрытый текст

JS: ленивая подгрузка вкладок и автокомплит:

// /local/modules/project.adminaccelerator/assets/admin-boost.js
(function () {
  function onReady(fn){ if (document.readyState !== 'loading') fn(); else document.addEventListener('DOMContentLoaded', fn); }
  function q(sel, root){ return (root||document).querySelector(sel); }
  function qa(sel, root){ return Array.from((root||document).querySelectorAll(sel)); }

  onReady(function () {
    // Ленивая подгрузка вкладок
    qa('.adm-detail-tabs-block .adm-detail-tab').forEach(function (tab) {
      tab.addEventListener('click', function () {
        var code = tab.getAttribute('data-tab-code');
        var target = q('.adm-detail-content-wrap[data-tab-code="' + code + '"]');
        if (target && !target.getAttribute('data-loaded')) {
          target.setAttribute('data-loaded', '1');
          target.innerHTML = '<div class="adm-info-message">Загружаем…</div>';
          var params = new URLSearchParams({
            action: 'project:adminaccelerator.AdminTab.load',
            elementId: (q('input[name=ID]') || { value: 0 }).value || 0,
            tabCode: code
          });
          fetch('/bitrix/services/main/ajax.php?' + params, { credentials: 'include' })
            .then(function (r) { return r.json(); })
            .then(function (data) { target.innerHTML = data.html || ''; })
            .catch(function () { target.innerHTML = '<div class="adm-info-message-red">Ошибка загрузки</div>'; });
        }
      }, { once: true });
    });

    // Автокомплит для HL-полей
    qa('[data-accel="hl-suggest"]').forEach(function (input) {
      var hlCode = input.getAttribute('data-hl');
      var dd = document.createElement('div');
      dd.className = 'accel-suggest'; input.parentNode.appendChild(dd);

      var timer = null;
      input.addEventListener('input', function () {
        clearTimeout(timer);
        var q = input.value.trim();
        if (q.length < 2) { dd.innerHTML = ''; return; }
        timer = setTimeout(function () {
          var url = '/api/admin/meta/hl/' + encodeURIComponent(hlCode) + '/suggest?q=' + encodeURIComponent(q);
          fetch(url, { credentials: 'include' })
            .then(function (r) { return r.json(); })
            .then(function (res) {
              dd.innerHTML = (res.items || []).map(function (it) {
                return '<div class="accel-suggest__item" data-id="' + it.id + '">' + it.text + '</div>';
              }).join('');
            });
        }, 180);
      });

      dd.addEventListener('click', function (e) {
        var item = e.target.closest('.accel-suggest__item');
        if (!item) return;
        input.value = item.textContent;
        var hidden = input.parentNode.querySelector('input[type=hidden]');
        if (hidden) hidden.value = item.getAttribute('data-id');
        dd.innerHTML = '';
      });
    });
  });
})();

CSS на минимум, чтобы не мешались подсказки:

/* /local/modules/project.adminaccelerator/assets/admin-boost.css */
.accel-suggest { position: relative; background:#fff; border:1px solid #c9d3dc; max-height:240px; overflow:auto; }
.accel-suggest__item { padding:6px 8px; cursor:pointer; }
.accel-suggest__item:hover { background:#eef2f7; }

Laravel: быстрые эндпоинты для админки и кеш метаданных

Когда админка тормозит, часто виноваты не «медленные сервера», а десятки тысяч строк, которые она зачем-то тянет в каждую форму. Справочники, свойства, подсказки — все грузится сразу и синхронно.

Мы пошли проще: сделали легкие REST-эндпоинты, которые отдают только нужное. Один — подсказывает значения из HL-справочников. Второй — отдает метаданные свойств. Оба защищены SSO и ролью admin, работают быстро и кешируются.

Как устроено

Подсказки из HL-справочников:

  • таблицы svc_hl_* собираются асинхронно по событиям;

  • при запросе мы читаем только первые 20 совпадений;

  • кешируем в Redis на минуту — для устойчивости и скорости.

Метаданные свойств:

  • отдаются по iblockId;

  • читаются из svc_iblock_props;

  • кешируются на 5 минут.

На Laravel ничего сложного: обычный контроллер с авторизацией и простым SQL. В Битриксе добавляем Managed Cache, чтобы даже эти быстрые REST-запросы не слать лишний раз. Таймауты жесткие: 180–300 мс для подсказок, 1 секунда — потолок.

При ошибке UI не разваливается, ведь возвращаемся к стандартному поведению Битрикс. Откат делаем через фича-флаг: отключаем ассеты модуля, и все возвращается как было.

В итоге контентщикам не нужно ничего переучивать: формы остаются прежними, вкладки на месте. Но теперь вместо того, чтобы грузить все сразу, они загружаются по запросу. Результат — тот же, но «холодный старт» стал внятным, а «Сохранить» не ждет справочник с городами.

Хотите выкатывать по шагам? Этот подход дружит с фича-флагами и спокойно живет рядом со старой схемой. А потом как пойдет.

Без подвисаний на «Сохранить»

Если в админке и есть что-то, что по-настоящему раздражает, то это работа с изображениями. Загрузил баннер, выбрал пресеты — и вот ты уже не сохраняешь, а ждешь, пока PHP крутит ресайзы, сжимает JPEG и расставляет водяные знаки.  А на фоне таймаут, дубль запроса и битые данные.

Мы выносим это из критического пути: Laravel берет на себя ресайзы, конвертацию, и отдает все через CDN. Bitrix не страдает, все работает как раньше, только быстро.

Поток загрузки:

  1. Форма в админке загружает файл на POST /api/media/upload.

  2. Laravel сохраняет оригинал в S3, создает запись media_assets и ставит задачи в очередь.

  3. Воркеры делают пресеты (WebP, AVIF, JPEG), пишут манифест.

  4. Bitrix сразу получает media_id, использует CDN-ссылки на /media/{id}/{preset}.

  5. Если пресет ещё не готов — Laravel отдает заглушку.

В Laravel используем Filesystem с диском media (S3-совместимый).

Скрытый текст

Контроллер метаданных и подсказок:

<?php

namespace App\Http\Controllers\Admin;

use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

final class MetaController
{
    public function hlSuggest(Request $request, string $code): JsonResponse
    {
        $this->authorizeAdmin($request);

        $q = (string) $request->query('q', '');
        if ($q === '' || \mb_strlen($q) < 2) {
            return response()->json(['items' => []]);
        }

        $cacheKey = 'hl:suggest:' . $code . ':' . \mb_strtolower($q);
        $items = Cache::remember($cacheKey, 60, function () use ($code, $q) {
            // Витрина для HL-справочника в нашей БД (svc_hl_{code})
            $table = 'svc_hl_' . Str::snake($code);
            return DB::table($table)
                ->select(['id', 'name'])
                ->where('name', 'like', $q . '%')
                ->orderBy('name')
                ->limit(20)
                ->get()
                ->map(fn ($r) => ['id' => (int) $r->id, 'text' => (string) $r->name])
                ->all();
        });

        return response()->json(['items' => $items]);
    }

    public function properties(Request $request): JsonResponse
    {
        $this->authorizeAdmin($request);

        $iblockId = (int) $request->query('iblockId');
        $cacheKey = 'admin:props:' . $iblockId;

        $props = Cache::remember($cacheKey, 300, function () use ($iblockId) {
            // Читаем из нашей витрины описаний свойств, собранной асинхронно из Битрикс
            return DB::table('svc_iblock_props')
                ->where('iblock_id', $iblockId)
                ->orderBy('sort')
                ->get(['code', 'name', 'type', 'is_heavy'])
                ->all();
        });

        return response()->json(['items' => $props]);
    }

    private function authorizeAdmin(Request $request): void
    {
        $user = $request->user();
        if (! $user || ! $user->hasRole('admin')) {
            abort(403);
        }
    }
}

Маршруты:

<?php

use App\Http\Controllers\Admin\MetaController;
use Illuminate\Support\Facades\Route;

Route::middleware(['bitrix.jwt'])->prefix('api/admin/meta')->group(function () {
    Route::get('/hl/{code}/suggest', [MetaController::class, 'hlSuggest']);
    Route::get('/properties', [MetaController::class, 'properties']);
});

На стороне Битрикс держим Managed Cache для часто используемых метаданных:

<?php

use Bitrix\Main\Application;

function accel_get_props(int $iblockId): array
{
    $cache = Application::getInstance()->getManagedCache();
    $key = 'accel:props:' . $iblockId;

    if ($cache->read(300, $key)) {
        /** @var array $props */
        $props = $cache->get($key);
        return $props;
    }

    $http = new \Bitrix\Main\Web\HttpClient(['socketTimeout' => 1, 'streamTimeout' => 1]);
    $json = (string) $http->get('https://api.example.ru/api/admin/meta/properties?iblockId=' . $iblockId);
    $res = \Bitrix\Main\Web\Json::decode($json);
    $props = (array) ($res['items'] ?? []);

    $cache->set($key, $props);
    return $props;
}

Пресеты для файлопомойки описываем в конфиге:

<?php // config/media.php

return [
    'disk' => env('MEDIA_DISK', 'media'),

    'presets' => [
        'thumb' => ['w' => 120,  'h' => 120,  'fit' => 'cover', 'format' => 'webp', 'q' => 82],
        'card'  => ['w' => 400,  'h' => 300,  'fit' => 'cover', 'format' => 'webp', 'q' => 82],
        'cover' => ['w' => 1600, 'h' => 600,  'fit' => 'cover', 'format' => 'avif', 'q' => 50],
        'orig'  => ['format' => 'jpeg', 'q' => 85], // безопасный JPEG для публичной раздачи
    ],
];

.env:

FILESYSTEM_DISK=media
MEDIA_DISK=media

AWS_ACCESS_KEY_ID=xxx
AWS_SECRET_ACCESS_KEY=xxx
AWS_DEFAULT_REGION=eu-central-1
AWS_BUCKET=project-media
AWS_URL=https://cdn.example.ru   # публичная раздача через CDN

config/filesystems.php (фрагмент):

'disks' => [
    'media' => [
        'driver' => 's3',
        'key'    => env('AWS_ACCESS_KEY_ID'),
        'secret' => env('AWS_SECRET_ACCESS_KEY'),
        'region' => env('AWS_DEFAULT_REGION'),
        'bucket' => env('AWS_BUCKET'),
        'url'    => env('AWS_URL'),
        'options' => [
            'CacheControl' => 'public, max-age=31536000, immutable',
        ],
    ],
],

Две таблицы: media_assets (оригинал и мета) и media_variants (пресеты и их состояние):

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('media_assets', function (Blueprint $t): void {
            $t->id();
            $t->string('uuid', 36)->unique();
            $t->string('mime', 64);
            $t->unsignedInteger('size');
            $t->unsignedInteger('width')->nullable();
            $t->unsignedInteger('height')->nullable();
            $t->string('path'); // s3 key оригинала
            $t->json('exif')->nullable();
            $t->timestamps();
        });

        Schema::create('media_variants', function (Blueprint $t): void {
            $t->id();
            $t->foreignId('media_id')->constrained('media_assets')->cascadeOnDelete();
            $t->string('preset', 32);
            $t->string('mime', 64)->nullable();
            $t->unsignedInteger('size')->nullable();
            $t->unsignedInteger('width')->nullable();
            $t->unsignedInteger('height')->nullable();
            $t->string('path')->nullable(); // s3 key пресета
            $t->string('status', 16)->default('pending'); // pending|ready|failed
            $t->text('error')->nullable();
            $t->timestamps();

            $t->unique(['media_id', 'preset']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('media_variants');
        Schema::dropIfExists('media_assets');
    }
};

Контроллер загрузки:

<?php

namespace App\Http\Controllers;

use App\Jobs\GeneratePresets;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;

final class MediaController
{
    public function upload(Request $request): JsonResponse
    {
        $data = $request->validate([
            'file' => ['required', 'file', 'max:12288', 'mimetypes:image/jpeg,image/png,image/webp,image/avif'],
        ]);

        $file = $data['file'];
        $uuid = (string) Str::uuid();

        // безопасное имя и ключ
        $key = 'orig/' . $uuid . '/' . preg_replace('~[^a-z0-9\.\-_]~i', '_', $file->getClientOriginalName());

        // пишем оригинал в S3
        Storage::disk(config('media.disk'))->put($key, file_get_contents($file->getRealPath()), [
            'visibility' => 'public',
            'ContentType' => $file->getMimeType(),
            'CacheControl' => 'public, max-age=31536000, immutable',
        ]);

        // можно здесь же нормализовать EXIF-ориентацию и перезаписать оригинал при необходимости

        $imageSize = @getimagesize($file->getRealPath());
        $width  = $imageSize[0] ?? null;
        $height = $imageSize[1] ?? null;

        $mediaId = DB::transaction(function () use ($uuid, $file, $key, $width, $height) {
            $id = DB::table('media_assets')->insertGetId([
                'uuid' => $uuid,
                'mime' => $file->getMimeType(),
                'size' => $file->getSize(),
                'width' => $width,
                'height' => $height,
                'path' => $key,
                'exif' => null,
                'created_at' => now(),
                'updated_at' => now(),
            ]);

            foreach (array_keys(config('media.presets')) as $preset) {
                DB::table('media_variants')->insert([
                    'media_id' => $id,
                    'preset'   => $preset,
                    'status'   => 'pending',
                    'created_at' => now(),
                    'updated_at' => now(),
                ]);
            }

            return $id;
        });

        // запускаем асинхронную генерацию
        GeneratePresets::dispatch($mediaId);

        return response()->json([
            'ok' => true,
            'media_id' => $mediaId,
            'uuid' => $uuid,
            'manifest' => [
                'orig' => $this->publicUrl($key),
                'ready' => false,
            ],
        ], 201);
    }

    private function publicUrl(string $key): string
    {
        return Storage::disk(config('media.disk'))->url($key);
    }
}

Маршруты:

<?php

use App\Http\Controllers\MediaController;
use Illuminate\Support\Facades\Route;

Route::middleware(['bitrix.jwt'])->group(function (): void {
    Route::post('/api/media/upload', [MediaController::class, 'upload']);
});

Из преимуществ такого подхода:

  • контентщик не ждет ресайза;

  • превью — по готовым CDN-ссылкам;

  • сервис не ломает админку — все совместимо;

  • фронт работает с готовыми форматами;

  • очередь — фоновая, воркеры масштабируются.

Это быстрая победа: UX остается прежним, но сохраняется за доли секунды, без подвисаний и гонки потоков. Если что-то пошло не так,.всегда можно откатить. Но в большинстве случаев оно просто начинает работать.

Когда контроллер загрузки не мешает жить

Когда Битрикс отправляет файл на POST /api/media/upload, Laravel:

  • проверяет тип и размер — без сюрпризов;

  • сохраняет оригинал в S3 с безопасным именем;

  • пишет мета: размеры, MIME, UUID;

  • создает будущие пресеты в статусе pending;

  • запускает задачу GeneratePresets в очередь.

На выходе — media_id,UUID и ссылка на оригинал. Даже повторная загрузка отработает аккуратно — процесс идемпотентен.

Фоновая очередь ресайзов

Каждая задача берет оригинал, проверяет, не готов ли уже нужный пресет, и если надо:

  • делает ресайз в нужном формате (cover или contain);

  • сжимает в AVIF, WebP или JPEG;

  • кладет в S3 с CDN-ключами;

  • обновляет media_variants: размеры, статус, путь.

Ошибки логируются, но не мешают другим пресетам. Удалить оригинал или попробовать снова можно когда угодно.

Скрытый текст
<?php

namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Imagick;

final class GeneratePresets implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 5;
    public int $backoff = 10;

    public function __construct(private readonly int $mediaId)
    {
    }

    public function handle(): void
    {
        $disk = Storage::disk(config('media.disk'));
        $media = DB::table('media_assets')->where('id', $this->mediaId)->first();
       if (! $media) {
            return;
        }

        $origKey = $media->path;
        $origTmp = tempnam(sys_get_temp_dir(), 'orig_');
        file_put_contents($origTmp, $disk->get($origKey));

        foreach (config('media.presets') as $preset => $cfg) {
            $row = DB::table('media_variants')
                ->where('media_id', $this->mediaId)
                ->where('preset', $preset)
                ->first();

            if ($row && $row->status === 'ready') {
                continue;
            }

            try {
                $img = new Imagick($origTmp);
                $img->setImageColorspace(Imagick::COLORSPACE_RGB);
                $img->setImageBackgroundColor('white'); // фон под непрозрачный JPEG
                $img = $this->resize($img, $cfg);

                $format = $cfg['format'] ?? 'webp';
                $q = (int) ($cfg['q'] ?? 82);

                if ($format === 'jpeg') {
                    $img->setImageFormat('jpeg');
                    $img->setImageCompressionQuality($q);
                    $img->setImageAlphaChannel(Imagick::ALPHACHANNEL_REMOVE);
                } elseif ($format === 'webp') {
                    $img->setImageFormat('webp');
                    $img->setImageCompressionQuality($q);
                } elseif ($format === 'avif') {
                    $img->setImageFormat('avif');
                    // для AVIF Imagick использует libheif, качество может отличаться
                    $img->setOption('heic:quality', (string) $q);
                }

                $key = 'variants/' . $media->uuid . '/' . $preset . '.' . $format;
                $disk->put($key, (string) $img, [
                    'visibility' => 'public',
                    'ContentType' => $this->mimeByExt($format),
                    'CacheControl' => 'public, max-age=31536000, immutable',
                ]);

                DB::table('media_variants')
                    ->where('media_id', $this->mediaId)
                    ->where('preset', $preset)
                    ->update([
                        'mime' => $this->mimeByExt($format),
                        'size' => $disk->size($key),
                        'width' => $img->getImageWidth(),
                        'height' => $img->getImageHeight(),
                        'path' => $key,
                        'status' => 'ready',
                        'updated_at' => now(),
                    ]);
            } catch (\Throwable $e) {
                DB::table('media_variants')
                    ->where('media_id', $this->mediaId)
                    ->where('preset', $preset)
                    ->update([
                        'status' => 'failed',
                        'error' => $e->getMessage(),
                        'updated_at' => now(),
                    ]);
            }
        }

        @unlink($origTmp);
    }

    private function resize(Imagick $img, array $cfg): Imagick
    {
        $w = $cfg['w'] ?? null;
        $h = $cfg['h'] ?? null;
        $fit = $cfg['fit'] ?? 'contain'; // contain|cover
       if ($w && $h) {
            if ($fit === 'cover') {
                $img->cropThumbnailImage($w, $h);
            } else {
                $img->thumbnailImage($w, $h, true);
            }
        } elseif ($w) {
            $img->thumbnailImage($w, 0);
        } elseif ($h) {
            $img->thumbnailImage(0, $h);
        }

        return $img;
    }

    private function mimeByExt(string $ext): string
    {
        return match ($ext) {
            'jpeg', 'jpg' => 'image/jpeg',
            'webp' => 'image/webp',
            'avif' => 'image/avif',
            'png' => 'image/png',
            default => 'application/octet-stream',
        };
    }
}

Эндпоинт, который всегда возвращает корректный URL:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\RedirectResponse;

final class MediaPublicController
{
    public function variant(int $id, string $preset): RedirectResponse
    {
        $row = DB::table('media_variants')->where('media_id', $id)->where('preset', $preset)->first();
        $asset = DB::table('media_assets')->where('id', $id)->first();

        $disk = Storage::disk(config('media.disk'));

        if ($row && $row->status === 'ready' && $row->path) {
            return redirect()->away($disk->url($row->path), 302);
        }

        // fallback - оригинал или статика-заглушка
        return redirect()->away($disk->url($asset->path ?? 'static/placeholder.png'), 302);
    }

    public function manifest(int $id): JsonResponse
    {
        $asset = DB::table('media_assets')->find($id);
        if (! $asset) {
            return response()->json(['ok' => false], 404);
        }

        $variants = DB::table('media_variants')->where('media_id', $id)->get()->map(function ($v) {
            return [
                'preset' => $v->preset,
                'status' => $v->status,
                'url' => $v->path ? Storage::disk(config('media.disk'))->url($v->path) : null,
            ];
        })->all();

        return response()->json([
            'ok' => true,
            'id' => $id,
            'orig' => Storage::disk(config('media.disk'))->url($asset->path),
            'items' => $variants,
        ]);
    }
}

Маршруты публичной раздачи:

<?php

use App\Http\Controllers\MediaPublicController;
use Illuminate\Support\Facades\Route;

Route::get('/media/{id}/{preset}', [MediaPublicController::class, 'variant'])->whereNumber('id');
Route::get('/api/media/{id}/manifest', [MediaPublicController::class, 'manifest'])->whereNumber('id');

В форме вместо загрузки "в файл" отправляем запрос на POST /api/media/upload и сохраняем в инфоблоке не бинарь, а media_id и нужные пресеты. Шаблоны фронта показывают картинки через /media/{id}/{preset} - это даёт кэшируемые, долговечные URL.

<?php

use Bitrix\Main\Web\HttpClient;
use Bitrix\Main\Web\Json;

function media_upload_from_bitrix(array $file): ?int
{
    $http = new HttpClient(['socketTimeout' => 3, 'streamTimeout' => 3]);
    $http->setHeader('Content-Type', 'application/octet-stream');
    $http->setHeader('X-Filename', $file['name']);

    $http->post('https://api.example.ru/api/media/upload', file_get_contents($file['tmp_name']));
    $res = Json::decode((string)$http->getResult());

    return (int)($res['media_id'] ?? 0) ?: null;
}

Далее поле типа "строка" или "число" хранит media_id. На стороне шаблона:

<img src="https://api.example.ru/media/<?= (int)$mediaId ?>/card" alt="">

Зачем вообще это все: ресайзы не должны мешать жить

В стандартной схеме все происходит синхронно: контентщик жмет «Сохранить», и PHP тут же начинает обрабатывать картинки — жмет, ресайзит, кладет в папки, добавляет водяные знаки. В этот момент сервер грустит, а форма превращается в таймер.

Мы это меняем. Разделяем «принять файл» и «обработать файл». Laravel забирает оригинал, кладет в S3 и говорит: «Принял, обрабатываю». А дальше очередь делает своё.

Раздача пресетов: если готов — отдали, если нет — заменили

Фронту все равно, есть ли готовый пресет. Он просто просит/media/123/card — и всегда что-то получает:

  • если пресет уже готов, это будет оптимальный формат с CDN;

  • если нет — отдаем оригинал или заглушку;

  • в любом случае — пользователь не видит пустоты.

Такой подход работает даже при нагрузке или деградации — не нужен фолбэк в шаблонах, все работает из коробки.

Как понять, что с изображением

По адресу /api/media/{id}/manifest можно получить полную картину:

  • какой пресет есть;

  • в каком он статусе (готов, pending, failed);

  • по каким URL их можно забрать.

Это можно использовать в UI, чтобы перерисовывать превью, или просто для мониторинга.

Почему UUID

UUID создается один раз при загрузке. Он попадает в путь к оригиналу и к каждому варианту. Если картинку перезаливают — путь меняется. Это важно:

  • CDN кэширует только нужное;

  • старые URL больше не актуальны;

  • нет «призраков» старых изображений в кэше.

А если вдруг что-то пошло не так, ошибки не ломают процесс. Пресет может не получиться — но остальные продолжат. Ошибки записываются в базу, попадают в Sentry, можно триггерить повторную генерацию.

А если изображение конфиденциальное — используем temporaryUrl() с подписью и временем жизни. Доступ строго по ссылке.

И самое важное: админка не ждет. С точки зрения пользователя ничего не поменялось — форма та же. Но теперь кнопка «Сохранить» отрабатывает за секунду. Админка живет, фронт показывает, сервер не перегружается — все на своих местах.

Хочу сразу предупредить, мы не воюем с Битриксом, не лепим рядом «нормальный движок» и не спорим, как надо было с самого начала. Мы работаем с тем, что есть — с десятками тысяч товаров, с редакторами, которым важно «чтобы не лагало», и с задачами, где нельзя подождать. Битрикс остается хозяином админки. Laravel берет на себя все, что должно быть быстрым: витрины, фильтры, справочники, медиа. Все это уживается в одной продовой экосистеме, без костылей и с уважением к зоне ответственности. 

Продолжение следует...

Комментарии (0)