Привет, хабровчане! На связи Алиса — тимлид в e-commerce агентстве KISLOROD.
Кто о чем, а я продолжаю рассказывать, как сшипперить Bitrix и Laravel. В первой части я рассказывала, как подружить Laravel с Битриксом так, чтобы никто не пострадал. Во второй — как устроить единый вход без шаринга сессий, ускорить каталог с OpenSearch, внедрить outbox-публикации и навести порядок в наблюдаемости. Теперь третий шаг — разгружаем чтение.
Каталог в Битриксе — система, проверенная временем. Она хорошо справляется с хранением и администрированием, но если нагрузить ее фильтрами, фасетами и сортировками, начинаются задержки, лишние запросы, теряется плавность работы. Мы решили не перегружать основную систему, а освободить ее от тяжелого чтения: вынесли все в Laravel и OpenSearch. Битрикс продолжает делать то, что умеет лучше всего, а быстрые ответы теперь приходят оттуда, где для этого все подготовлено.
Как это работает
Путь данных устроен просто и надежно:
Контентщик нажимает «Сохранить» в Битриксе — привычный процесс не меняется.
Событие попадает в outbox, где фиксируется и ждет своего часа, даже если очередь временно недоступна.
Через Redis Streams событие уходит в Laravel — быстро и без лишних зависимостей.
Консюмер обновляет витрины в
svc_catalog_*, готовит данные для поиска.Документ индексируется в 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, если найден — отдаем готовый к рендеру объект.
Обновление индекса — через события. Как только товар меняется:
Событие попадает в outbox.
Laravel консюмер получает его через Streams.
Собирает свежую карточку из витрин
svc_catalog_*.Формирует документ.
Обновляет его в OpenSearch.
Сбивает кеш — чтобы все пересчитать на фронте.
Если товар удален — просто удаляем документ из индекса.
Скрытый текст
Сначала поставим клиент и опишем обертку.
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 и распределяет события. Теперь добавляем к ней конкретную задачу — обновить витрину товара и отправить его в индекс.
Как это работает:
Из события берём
idтовара.Тянем свежие данные из витрины
svc_catalog_product.Если товара больше нет — удаляем из индекса.
Иначе собираем все нужные куски: свойства, остатки, мета.
Собираем документ — и в
upsert.Не забываем: сбрасываем кэш карточки и листинга, чтобы все обновилось.
Получается, одно событие — один индексированный документ. Пришло — обработали, обновили, отдали. Если что-то пошло не так, 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.
Админка та же, только быстрее
Когда контентщики жалуются, что «Битрикс тормозит», чаще всего речь не о сохранении. Сама кнопка «Сохранить» работает нормально. Болит другое — открытие карточки. Пока подтянутся справочники, пока загрузятся свойства, пока все это отрендерится — человек уже успел выпить чаю.
Но мы не ломаем интерфейс, не переучиваем команду. Вместо этого — встраиваем тонкую прослойку, которая делает все, как раньше, только быстрее:
Лениво подгружаем вкладки — данные подтягиваются только при первом клике.
Справочники и автокомплиты — переводим на быстрые REST-эндпоинты в Laravel, без лишней нагрузки.
Метаданные и списки — кешируем на несколько минут, чтобы не гонять одно и то же.
Внешне — ничего не поменялось. Контентщик работает в привычной форме. Но «холодный старт» карточки перестает тянуться вечность, а сохранение больше не зависит от ответа внешнего сервиса.
Архитектурно это работает так. В Битриксе мы помечаем «тяжелые» блоки формы — те, что обычно тормозят — плейсхолдерами. Вместо того, чтобы грузить их сразу, подключаем легкий 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 не страдает, все работает как раньше, только быстро.
Поток загрузки:
Форма в админке загружает файл на
POST /api/media/upload.Laravel сохраняет оригинал в S3, создает запись
media_assetsи ставит задачи в очередь.Воркеры делают пресеты (WebP, AVIF, JPEG), пишут манифест.
Bitrix сразу получает
media_id, использует CDN-ссылки на/media/{id}/{preset}.Если пресет ещё не готов — 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 берет на себя все, что должно быть быстрым: витрины, фильтры, справочники, медиа. Все это уживается в одной продовой экосистеме, без костылей и с уважением к зоне ответственности.
Продолжение следует...