Если вы видите это, значит, я еще не придумал, что написать.
Manticore Search — быстрый поисковый движок с полнотекстовым поиском, фасетами и векторным KNN. У него есть официальный PHP-клиент, manticoresoftware/manticoresearch-php, который поддерживает вендор и который идёт в ногу с релизами сервера. Тем не менее я написал свой. Ниже — три конкретные вещи, из-за которых это произошло, и то, чем за них пришлось заплатить.
Задача была обычная: полнотекстовый поиск по каталогу в приложении на Laravel. Manticore подошёл, официальный клиент поставился, первый запрос заработал за десять минут:
use Manticoresearch\Client;
$client = new Client(['host' => '127.0.0.1', 'port' => 9308]);
$result = $client->table('products')
->search('galaxy')
->filter('price', 'lte', 1000)
->sort('price', 'desc')
->limit(10)
->get();
foreach ($result as $hit) {
echo $hit->title;
}
Казалось бы, все хорошо, у клиента есть “текучий” интерфейс, ResultSet реализует Iterator и Countable, а ResultHit через __get() отдаёт поля как свойства. Придраться тут не к чему.
Но чего-то все равно не хватало…
Manticore говорит на двух языках. По порту 9308 — JSON поверх HTTP, по порту 9306 — SQL по протоколу MySQL. Официальный клиент умеет только первое.
В Laravel-проекте второе оказалось удобнее по четырём причинам.
PDO-расширение уже есть. Расширение ext-pdo входит в стандартную сборку PHP и заведомо включён в любом проекте, который ходит в базу через DB::. Отдельный HTTP-стек ради поиска добавлять не нужно.
Порт уже проброшен. Порт 9306 говорит на том же протоколе, что MySQL, и вписывается в готовую сетевую обвязку: те же правила фаервола, те же туннели, тот же способ пустить приложение к базе в докере. Отдельный HTTP-порт — это отдельный разговор с теми, кто эту обвязку настраивал.
Запрос можно выполнить руками. Логирование отдаёт готовый SQL, который копируется в консольный mysql -P9306 и выполняется как есть:
// глобально, на конкретное соединение или на один запрос
\ManticoreDb::setLogger(\Log::getLogger());
Это обычный цикл отладки, к которому разработчик уже привык по работе с базой. С JSON-телом запроса так не выйдет: его сначала нужно превратить во что-то, что можно скормить серверу вручную.
SQL-интерфейс шире. JSON API покрывает поиск и модификацию данных. Всё остальное — SHOW, ALTER, FLUSH, служебные CALL-процедуры — живёт только в SQL. А значит, рано или поздно понадобится “просто выполнить SQL-запрос”:
\ManticoreDb::connection()->statement('FLUSH RAMCHUNK products');
Была еще мысль, что PDO будет работать быстрее, чем HTTP API, но по факту оказалось, что это не так, во всяком случае, у меня в бенчмарках расхождение оказалось на уровне погрешности, так что этот аргумент точно мимо.
В Manticore нет привычных баз данных и схем. Одно плоское пространство имён на весь демон — все таблицы лежат рядом.
И пока демон один на проект, это не играет никакой роли. Как только он становится общий на несколько проектов (а у меня было так) — начинается ручная работа. Таблицы прода и дева, таблицы соседнего проекта, таблицы, которые создал в тестах и забыл, все они живут в одном списке и норовят столкнуться именами.
Ровно от этого Laravel избавляет в работе с базой уже лет десять, но официальный клиент, к сожалению, ничего такого не умеет, значит, префикс придётся приклеивать самому — в каждом вызове, из конфига, руками.
Поэтому в конфигурации есть префикс, а в имени таблицы — плейсхолдер для него:
MANTICORE_PREFIX=myapp_
// читает таблицу myapp_products
\ManticoreDb::table('?products')->match('galaxy')->get();
Знак ? в начале имени — единственное, что нужно написать. Если приложение работает только со своими таблицами, знак ? можно не писать вовсе:
MANTICORE_FORCE_PREFIX=true
Тогда префикс добавится к любому имени таблицы. Зачем тогда плейсхолдер, а не всегда безусловный префикс по умолчанию? А это отработка частного случая, когда в коде и к своим, и к чужим таблицам, и их имя трогать нельзя. Явный знак ? разделяет “свои” таблицы и “чужие” (которые читаются, как есть) прямо в коде запроса, а не в голове.
Вот пример запроса из php через официального клиента:
$result = $client->table('products')
->search('galaxy')
->filter('price', 'lte', 1000)
->sort('price', 'desc')
->limit(10)
->get();
Этот же запрос с помощью монй библиотеки:
$rows = \ManticoreDb::table('products')
->match('galaxy')
->where('price', '<', 1000)
->orderBy('price', 'desc')
->limit(10)
->get();
Строчка в строчку то же самое, но во втором случае разработчику не нужно помнить, что вместо where здесь надо писать filter, оператор сравнения пишется словом lte, а сортировка — sort. Используя библиотеку, разработчик пишет where и orderBy, потому что весь остальной код в проекте написан так. Экономия — не в символах, а в отсутствии переключения.
А дальше начинается то, ради чего нужна именно интеграция с фреймворком, а не просто похожие имена методов.
Ответ — коллекция. Метод get() возвращает Illuminate\Support\Collection, а строка — объект Row:
$rows = \ManticoreDb::table('products')->match('galaxy')->get();
$titles = $rows->map(fn ($row) => $row->title)->all();
Row при этом читается и как объект, и как массив:
$row->title; // привычно для Laravel
$row['title']; // так же, как отвечает ядро пакета
$row->toArray();
json_encode($rows); // массив объектов, как и ждёт JSON API
Постраничная навигация — штатная. С теми же аргументами, что у DB::, и с тем же links() в шаблоне:
$products = \ManticoreDb::table('products')->match('galaxy')->paginate(15);
@foreach ($products as $product)
{{ $product->title }}
@endforeach
{{ $products->links() }}
Есть и simplePaginate() — без COUNT(*), когда шаблону нужны только “вперёд” и “назад”.
Транзакции пишутся как обычно. Manticore выполняет BEGIN / COMMIT / ROLLBACK на real-time таблицах:
\ManticoreDb::transaction(function ($connection) {
$connection->table('products')->insert($data);
$connection->table('log')->insert($record);
});
Миграция — обычный класс миграции:
use avadim\Manticore\QueryBuilder\Schema\SchemaTable;
public function up()
{
\ManticoreDb::create('products', function (SchemaTable $table) {
$table->timestamp('created_at');
$table->string('name');
$table->text('description');
$table->float('price');
});
}
public function down()
{
\ManticoreDb::drop('products', true);
}
Ошибка запроса — исключение, а не пустая выборка. Отклонённое чтение бросает QueryErrorException: неверный запрос — это дефект, и молча вернуть ноль строк здесь хуже, чем упасть.
use avadim\Manticore\QueryBuilder\QueryErrorException;
try {
$rows = \ManticoreDb::table('products')->match($text)->get();
}
catch (QueryErrorException $e) {
report($e);
$rows = collect();
}
Чаще всего запрос отклоняет полнотекстовая строка, пришедшая от посетителя, — её стоит пропустить через \ManticoreDb::escapeMatch($text) до попадания в match().
Я сделал два пакета: avadim/manticore-query-builder-php — ядро без единой зависимости от фреймворка, и avadim/manticore-query-builder-laravel — обвязка поверх него.
Разделение не ради красоты. Ядро отвечает обычными массивами именно потому, что Collection в нём означала бы illuminate/support в библиотеке, которая должна работать и в проекте на Symfony, и в скрипте без фреймворка вообще. А обвязка добавляет ровно то, что имеет смысл только внутри Laravel: сервис-провайдер, файл конфигурации, именованные соединения и ответы в том виде, в каком их ждёт фреймворк.
Для приложения это значит, что до одного и того же соединения ведут три равнозначные дороги:
use avadim\Manticore\Laravel\Manager;
// глобальный алиас, который регистрирует сам пакет
\ManticoreDb::table('products')->find($id);
// фасад, если глобальный алиас не по душе
ManticoreDb::table('products')->find($id);
// внедрение зависимости, если класс должен тестироваться без фреймворка вокруг
public function __construct(Manager $manticore) { … }
Транзакционного DDL в Manticore нет. Миграция, упавшая на середине, оставит всё, что успела создать. Писать down() нужно так, чтобы он отработал и на недостроенной таблице.
Одна операция на выражение. ADD COLUMN a, ADD COLUMN b сервер не примет — это два запроса, и падение второго оставит первый применённым.
Новая колонка не переиндексирует старые строки. Полнотекстовое поле, добавленное к работающей таблице, найдёт только то, что записано после него. Чтобы колонка что-то значила, данные нужно записать заново.
Кеш схемы живёт столько же, сколько соединение. Соединение запоминает DESCRIBE каждой таблицы, чтобы не спрашивать сервер перед каждым запросом и приводить значения к типам PHP.
Если вы меняете схему с помощью методов самого пакета – create(),
alter(), addColumn(), dropColumn() и прочие – библиотека знает об этих изменениях, сбрасывает кеш схемы и и никаких проблем не возникает. Но бывают ситуации, когда кеш схемы надо сбрасывать явно:
\ManticoreDb::forgetSchema();
Условие для сборса кеша всегда одно и то же — схему изменили мимо вашего соединения, а объект Connection пережил это изменение. На практике это четыре случая.
1. ALTER через statement(). Сырой SQL проходит мимо билдера, и сбросить кеш ему
нечем:
\ManticoreDb::connection()->statement('ALTER TABLE products ADD COLUMN rating int');
\ManticoreDb::forgetSchema();
2. Миграция в другом процессе. php artisan migrate отработал, а воркер Horizon или
Octane, поднятый до неё, продолжает жить со старым кешом. Самый частый случай, потому что
деплой обычно так и выглядит.
3. Таблицу изменил кто-то ещё — indexer, соседний сервис, другое приложение на том же демоне.
4. Другое именованное соединение того же приложения. Изменили через
connection('admin'), читаете через connection('default') — это два разных пула.
Важно помнить: под php-fpm соединение живёт ровно один HTTP-запрос, поэтому в контроллере вызывать forgetSchema() бессмысленно почти всегда: кеш и так не переживёт ответ.
Долгоживущие процессы — Octane, очереди, планировщик, длинные CLI-команды — единственное место, где это
реально нужно.
Отдельно стоит держать в голове тесты: если тест-кейс создаёт и удаляет таблицы через
statement() и переиспользует соединение между тестами, кеш надо сбрасывать в setUp(),
иначе следующий тест увидит схему предыдущего.
Если вы работаете именно по HTTP JSON API — например, тот же код ходит в Manticore из нескольких языков и формат запросов общий. Если инфраструктура не пускает вас на 9306. И если важно, чтобы клиент поддерживался вендором и выходил вместе с релизами сервера.
Мой пакет — SQL-only, HTTP JSON API он не использует вовсе. Это осознанное ограничение, а не недоделка: первая из трёх причин, по которым он написан, ровно в том и состоит, что он говорит с сервером на SQL.
Post::search('manticore')->get() по моделям EloquentМой Telegram канал 👉 https://t.me/agoalofalife_channel

Полгода делал большой курс по Temporal и наконец закончил. Хочу поделиться с сообществом – тем более что весь проект построен на знакомом стеке: Laravel + RoadRunner + Temporal PHP SDK.
Получилось 18 видео, около 16 часов контента. Если считать всё «за кадром» – подготовку, записи и монтаж в свободное время — вышло примерно 133 часа работы, больше обычного рабочего месяца.
Это не набор толко академических видео, а бизнес-история одного проекта. Мы шаг за шагом строим агрегатор доставки еды и на каждом уроке добавляем новую возможность Temporal — от первого workflow до production-настройки воркеров. Движемся от простого к сложному, всё привязано к реальной бизнес-логике, а не к абстрактным примерам.
Если коротко, после курса вы будете понимать:
Основы
Реактивность
Масштабирование логики
Надёжность
Продвинутое и Production
Весь материал доступен плейлистом на YouTube. Исходный код каждого урока лежит на GitHub.
Материала по Temporal ещё много, и останавливаться я не планирую. Но думаю над форматом продолжения — что было бы полезнее сообществу: разборы реальных кейсов, глубокие технические дайвы во внутренности SDK или что-то ещё? Буду рад мнению в комментариях.
Если курс окажется полезным — заходите на YouTube и в телеграм, там я продолжаю писать про Temporal и смежные темы. Отдельно собрал подборку материалов: чаты, статьи и ресурсы по Temporal.
Буду благодарен за конструктивную критику и вопросы.
Мержим, пушим, билдим, фиксим ....
Тут недавно был отличный пост про то, как вынести
~/.claudeв git-репозиторий. Я делаю так же — но у git-подхода есть слепое пятно: он версионирует то, что Claude умеет, а не то, что он узнал про твои проекты. Вот как я закрыл это для команды.
Тот самый пост — про то, как превратить ~/.claude в git-репозиторий: скиллы, агенты, слэш-команды, MCP-конфиг, хуки под симлинками через make install, синк между машинами, не теряется при блокировке аккаунта. Подход правильный, сам так делаю.
Но за 2 года на нескольких проектах я упёрся в то, что git-конфиг не решает.
Git версионирует статику — то, что Claude умеет: твои скиллы, агентов, команды, правила. Это здорово и переносимо.
Чего там нет — это динамики: того, что Claude узнал про конкретный проект за прошлые сессии. Например:
migrate, а через свою обёртку над artisan;Это не скилл и не правило — это накопленный контекст по проекту. В Claude Code он копится через auto-memory, но живёт в отдельной базе, а не в ~/.claude/*.md. Поэтому в git он не попадает. А значит:
Git-pull этого по своей природе не лечит: он пофайловый и point-in-time. А память по проекту нужна живой, общей и искомой по смыслу, а не «закоммить — запушь — подтяни».
Вынес слой памяти на сервер. ruflo-hub — небольшая Docker-обёртка: берёт MCP-сервер памяти, оборачивает stdio в HTTP, и Claude Code у всех в команде ходит в одно общее хранилище памяти. Один человек разобрался в особенности проекта — и у остальных Claude это уже знает.
Сразу честно, иначе про этот проект нельзя. В основе — ruflo (форк claude-flow), и у него репутация. Я сам проверял: большая часть из «300+ MCP-инструментов» — нерабочие заглушки. swarm_init оставляет agentCount: 0, neural_train возвращает Math.random(), «агенты» — это markdown-файлы. Как swarm-оркестратор это в основном театр (об этом же — обсуждение на r/ClaudeAI).
Реально работает ровно одна часть — слой памяти: настоящая ONNX-модель (MiniLM) + HNSW-индекс, SQLite-персист и auto-memory-хук. ruflo-hub — тонкая обёртка, которая отдаёт по сети только этот слой и мостит его в Claude Code. Никакого swarm/neural и сотен заглушек в контексте — мы их просто не грузим. То есть это ровно тот рабочий ~1% от ruflo, без остального.
Поднять сервер:
docker compose up -d
curl http://localhost:3000/health
Память хранится в SQLite (memory.db, WAL-режим). PostgreSQL в комплекте опционален — он не основное хранилище, а нужен только под ruflo ruvector import/export.
Подключение проекта — самоконфигурирующийся скрипт с того же сервера:
curl http://<сервер>:3000/setup | bash
Да, это curl | bash со своего сервера — скрипт отдаётся в открытом виде, гляньте перед запуском. Он кладёт хелперы в .claude/helpers/ (auto-memory-хук + statusline) и правит .claude/settings.json (хуки SessionStart → import / Stop → sync). Дальше Claude Code пишет и читает память на сервере сам: memory_store на находки, memory_search — по смыслу (не grep: найдёт «паттерн JWT-авторизации» по запросу «token-based login flow»). В statusline видно число векторов и статус MCP.
@claude-flow/memory. Зарепортили апстриму (#2432), и в ruflo 3.14.2 это уже исправлено; у себя держим ещё RSS-watchdog как страховку.ruflo@latest и пересобирается еженедельно, так что :latest со временем уедет вперёд. Аудит и фиксы ниже я делал на 3.14.2. Хотите воспроизводимость — берите конкретный тег (:1.3.0 или :<sha>), а не :latest, и проверяйте docker exec … ruflo --version.ruflo (3.1.0-alpha.55 – 3.5.2) был #1375: вредоносный preinstall-скрипт и скрытая инъекция в описаниях инструментов. Раз хаб ставит пакет и раздаёт описания клиентам — перепроверил версию, которую шиплю (3.14.2): preinstall-хуков нет, все 305 описаний чистые. В этой версии вылечено — но фиксируйте версию и проверяйте сами.memory.db в WAL-режиме, бэкапим том целиком (memory.db + -wal + -shm); копия одного memory.db даёт database disk image is malformed.Когда я кидал это в один Laravel-чат, первым прилетело не «как», а «а как с безопасностью общей памяти» и «чем это лучше папки docs + claude.md». Отвечу честно.
«А если кто-то накидает в общую память инъекций? Джун насыплет своих инструкций? А если они нужны только в одном проекте, а в другом нет?»
Память разводится по namespace — Claude сам заводит их по проектам, плюс можно явно сказать «сохрани/прочитай в такой-то namespace». Если у проектов разные trust-зоны и утечки между ними недопустимы — это не «одна общая помойка»: поднимаешь отдельный ruflo-hub под бизнес-скоуп (один сервер = один периметр доверия), личное — на своём.
А вот честное ограничение: per-user ACL и автофильтра инъекций из коробки пока нет. Сейчас это организационно — общая память это общая граница доверия: подключаешь тех, кому доверяешь, ревью памяти на тимлиде. Технического «джун не может писать сюда» я ещё не сделал — и не буду делать вид, что сделал. Если у тебя в команде это критично — пока только разводка по отдельным серверам.
«Чем лучше папки docs/ и claude.md?»
Они никуда не деваются, и память их не заменяет. Разница в двух вещах. Первое — часть рабочих вещей в гит класть нельзя или не нужно, а память не в гите. Второе — память живая и сразу доступна агенту через хуки + поиск по смыслу. Часто в ней лежат как раз ссылки на эти доки: Claude, просматривая память, сам быстро находит «а, про это есть док вот тут» — даже если конкретный разработчик не знает, что кто-то положил эту доку сто лет назад. То есть docs/claude.md — статика для людей, а память — индекс по опыту для агента, который на эту статику ссылается.
Зеркалю мысль из того поста — не ради инфраструктуры:
Нужно — когда команда и много проектов, и хочется, чтобы то, что Claude понял на одном проекте или у одного человека, доходило до всех без пересказа.
~/.claude в git и общая память — про разные половины одной задачи: статика (что Claude умеет) едет в git, динамика (что он узнал про твои проекты) — в общий сетевой слой памяти. У меня работает связка из обоих.
jazz-max/ruflo-hub (образ на Docker Hub — jazzmax/ruflo-hub; да, ник на GitHub и в Docker Hub исторически разный){message}