Привет, Хабр! Пишу свою первую статью. Уже больше десяти лет работаю с PHP: делаю админки, CRM, интернет-магазины и внутренние системы.
Почти в каждом проекте рано или поздно появляется запрос «чтобы без перезагрузки». И рядом с PHP вырастает второй кусок: фронтенд со сборщиком, node_modules, API между двумя частями, валидация в двух местах, а состояние то на сервере, то в браузере. Меня это достало, и я создал rig. Это PHP-фреймворк, где интерфейс откликается сразу, а приложение остаётся обычным PHP-проектом. Расскажу, как он устроен, что уже работает и где его лучше не использовать.
Как выглядит компонент
Один компонент живёт в одном .php-файле. Сверху класс с состоянием и логикой, под ним шаблон и стили.
<?php namespace App\Components; use Rig\Attr\{Action, Computed}; use Rig\Component; final class Counter extends Component { public int $count = 0; #[Computed('count * 2')] public int $double; #[Action(optimistic: 'count = count + 1')] public function increment(): void { $this->count++; } } ?> <template> <p>{{ count }} × 2 = {{ double }}</p> <button @click="increment">+</button> </template> <style> p { font-size: 24px; } </style>
На страницу его вставляют тегом:
<?php $title = 'Счётчик'; ?> <h1>Счётчик</h1> <Counter />
Вот что происходит, когда нажимают «+»:
Число меняется сразу. Оптимистичное выражение
count = count + 1браузер выполняет сам, не дожидаясь ответа сервера.×2пересчитывается мгновенно:#[Computed('count * 2')]работает и в PHP, и в браузере.На сервер уходит JSON-запрос: «у этого Counter с текущим состоянием вызови
increment()».PHP исполняет обычный метод класса и возвращает новое состояние.
Браузер меняет только те узлы DOM, которые действительно изменились.
JavaScript в компоненте и на странице нет вообще. Он живёт только внутри рантайма фреймворка, весит 21 КБ в gzip, и заглядывать в него при работе над приложением не придётся.
Зачем
Для бэкенд-команды живой интерфейс стоит дорого. Логика разбросана между PHP и JS, и каждый в команде знает только свою часть. Возьмём кнопку «Удалить» без перезагрузки: нужен эндпоинт, сериализация, вызов из браузера и обработка ошибок. При этом пользователь и сервер всё равно работают в одном приложении. Валидацию, права и формат дат пишешь дважды. У фронтенда свой пайплайн и свои зависимости, их тоже нужно поддерживать. А если интерфейс собирается в браузере, его ещё учат рендериться на сервере, иначе поисковики увидят пустую страницу.
В rig это одно приложение на одном языке. Шаблон по умолчанию рендерится на сервере, так что поисковик сразу получает готовый HTML. Браузер потом «оживляет» эту разметку. Всё, где нужны данные, выполняется в PHP, рядом с базой, правами и бизнес-логикой.
Как это устроено
Один язык выражений и два интерпретатора
Самое приятное в rig для меня это шаблоны. Выражения в них пишутся на небольшом языке, который понимают и сервер, и браузер: {{ count }}, :class="{active: filter == f}", r-for="t in todos", @click="remove(t.id)".
При компиляции PHP разбирает шаблон в дерево (AST из обычных массивов). Это дерево исполняют два интерпретатора: PHP, когда рендерит страницу, и рантайм в браузере. Сравнение, приведение типов, деление на ноль и вывод в строку работают одинаково, до последнего символа. Чтобы так и оставалось, есть тесты: одно и то же дерево прогоняется через оба интерпретатора, и результаты сравниваются.
Что это даёт на практике? Всё, что можно посчитать без сервера, браузер считает сразу. А когда нужны данные, считает PHP. HTML с сервера приходит ровно таким, каким его нарисовал бы браузер, поэтому при гидрации страница не перерисовывается.
Ошибки ловит компилятор
Шаблоны компилируются заранее, и каждое выражение сверяется со свойствами класса. Ошиблись, и компиляция остановится:
✖ TodoItem Неизвестная переменная «nmae» — может быть, «name»? app/components/TodoItem.php:15
В режиме разработки та же ошибка показывается отдельной страницей: код вокруг строки, кнопка «Открыть в редакторе» и готовый промпт для ИИ-агента. В промпте уже лежат ошибка, код, путь вызова и задача найти причину и поправить её минимальным изменением.
Где живёт состояние
За это отвечает атрибут над свойством:
public array $todos = []; // на сервере, между запросами лежит в подписанном снимке #[Locked] // браузер не может прислать новое значение public int $userId; #[Client] // только в браузере: вкладки, модалки, фильтры, без запросов public string $filter = 'all'; #[Computed("where(todos, 'done', false) | len")] public int $left; // считается и в PHP, и в браузере #[Shared('cart', persist: true)] // одно значение для всех компонентов с этим ключом public array $cart = [];
Серверное состояние между запросами хранится прямо на странице, в снимке с подписью HMAC-SHA256. Без ключа приложения его не подделаешь. Заодно серверу не нужно помнить каждую открытую вкладку.
Вложенные компоненты
Один компонент вставляется в шаблон другого тегом. Родитель отдаёт ему данные через атрибуты, а дочерний сообщает событиями, что сделал пользователь. Как на это реагировать, решает родитель.
Например, строка списка задач получает задачу через #[Prop] и сообщает о клике через emit():
// компонент TodoItem: app/components/TodoItem.php final class TodoItem extends Component { public const TAG = 'li'; // компонент рендерится внутри <li>, а не <div> #[Prop] public array $todo = []; } ?> <template> <input type="checkbox" :checked="todo.done" @change="emit('toggle', $event.target.checked)"> {{ todo.title }} <button @click="emit('remove')">×</button> </template>
Список выводит по строке на каждую задачу и на её события вызывает свои действия. В $event попадает то, что строка передала в emit(), в нашем случае состояние чекбокса:
<!-- компонент TodoList: шаблон в app/components/TodoList.php --> <ul> <TodoItem r-for="t in todos" :key="t.id" :todo="t" @toggle="setDone(t.id, $event)" @remove="remove(t.id)" /> </ul>
Иногда дочерний компонент меняет только одно-единственное значение. Тогда удобно привязать его к родителю через r-model, как обычное поле ввода. Для этого помечаем свойство атрибутом #[Model]:
// компонент Rating: app/components/Rating.php final class Rating extends Component { #[Prop, Model] public int $value = 0; } ?> <template> <button r-for="n in [1, 2, 3, 4, 5]" :key="n" :class="{on: n <= value}" @click="value = n">★</button> </template>
<Rating r-model="score" /> <!-- клик по звезде меняет score у родителя -->
Разметку внутрь компонента передают слотами. В шаблоне карточки оставлены места <slot> и <slot name="footer">. Снаружи это выглядит так:
<Card title="Задачи"> <TodoList /> <template #footer>Двойной щелчок по задаче переименовывает её</template> </Card>
Почему у TodoList нет атрибутов? Потому что он сам загружает задачи в mount() и сам меняет их через setDone() и remove(). Если бы список приходил извне через #[Prop], менять его внутри компонента было бы нельзя, и все действия пришлось бы переносить в родителя. Для примера сделано внутри компанента.
Переходы, кеш и ленивые данные
Ссылки внутри сайта открываются без полной перезагрузки. Сервер отдаёт только содержимое нужной страницы, а layout и его компоненты остаются на месте со своим текущим состоянием.
Страница, с которой ушли, сохраняется в rig вместе с живыми компонентами. Поэтому кнопка «Назад» показывает её мгновенно, без нового запроса, с теми же фильтрами, подгруженными товарами, введённым текстом и текущей прокруткой. Если за это время данные изменились, компоненты тихо обновятся в фоне.
Пока курсор находится над ссылкой, rig уже запрашивает страницу, и к моменту клика ответ готов.
Медленные данные страница не ждёт. Компонент с #[Lazy] приходит сразу в виде скелетона, а тяжёлая выборка делается отдельным запросом, когда страница уже отображена. Если пользователь за это время ушёл дальше, переход тоже не ждёт эту выборку.
#[Lazy] final class ProductList extends Component { #[Locked] public array $items = []; #[Locked] public int $page = 0; #[Locked] public bool $hasMore = false; public function mount(): void { $this->more(); // первая страница: медленная выборка } #[Action] public function more(): void { $p = Product::orderBy('id')->paginate(9, $this->page + 1); $this->items = [...$this->items, ...$p['items']]; $this->page = $p['page']; $this->hasMore = $p['page'] < $p['pages']; } } ?> <template> <ul class="grid"> <li r-for="p in items" :key="p.id">{{ p.name }}, {{ p.price }} ₽</li> <!-- пока грузится следующая страница, показываем три скелетона --> <li r-for="i in ($pending.more ? [1, 2, 3] : [])" :key="'sk' + i" aria-hidden="true"> <div class="r-sk r-sk-block"></div> </li> </ul> <button r-if="hasMore" @click="more" :disabled="$busy"> {{ $pending.more ? 'Загружаем…' : 'Показать ещё' }} </button> </template> <!-- скелетон, видимый пока выполняется mount() --> <skeleton> <ul class="grid" aria-hidden="true"> <li r-for="i in [1, 2, 3, 4, 5, 6]" :key="i"> <div class="r-sk r-sk-block"></div> <div class="r-sk r-sk-line" style="width: 60%"></div> </li> </ul> </skeleton>
Скелетон пишется тем же синтаксисом, что и шаблон, и стили компонента к нему тоже применяются. Для плашек есть готовые классы r-sk, r-sk-line и r-sk-block. Они мерцают, учитывают тёмную тему и prefers-reduced-motion.
События с сервера
$this->broadcast('todos-changed'); // другим вкладкам и другим пользователям #[On('todos-changed')] public function reload(): void { $this->todos = Todo::all(); }
Доставкой занимается хаб. У вкладки одно SSE-соединение, и по нему приходят только те события, которые слушают компоненты на странице. Список событий подписан, поэтому подписаться на чужие события вкладка не сможет.
Когда JavaScript всё-таки нужен
Графики, карты и редакторы уже есть в готовых библиотеках, переписывать их в шаблонах нет смысла. Для них есть r-js: содержимым элемента управляет ваш модуль.
<div r-js="sparkline" r-props="{points: points}" @pick="picked = $event.detail"></div>
// app/js/sparkline.js export function mount(el, props, ctx) { // рисуем; ctx.emit('pick', value) отправит событие обратно в шаблон return { update(next) { /* новые данные из PHP */ }, unmount() {}, }; }
Модуль получает данные из PHP и отвечает событиями шаблона. Всё остальное работает без JS.
Бэкенд в комплекте
У rig свой бэкенд, Composer-пакеты не нужны.
PostgreSQL для разработки ставить не нужно. rig dev сам создаёт кластер проекта в storage/postgres, запускает его на отдельном порту и накатывает миграции. На сервере используется обычный PostgreSQL, адрес берётся из .env. MySQL и SQLite тоже работают.
$todos = Todo::where('done', false)->orderBy('id')->get(); DB::table('orders') ->where('total', '>', 1000) ->paginate(20, $page); DB::transaction(function () { // … });
Модели приводят типы и строят связи. Миграции откатываются пачками, начальные данные заводятся сидами.
Доступ ограничивается middleware на странице и атрибутом #[Guard] на компоненте:
<?php Rig\Page::middleware('role:admin'); // первая строка страницы
#[Guard('auth')] // компонент и все его действия доступны только вошедшим final class Profile extends Component { … }
Список middleware страницы попадает в подписанные снимки её компонентов, и действия проверяются по этому же списку. Поэтому выключить проверку из браузера не получится. Есть ещё сессии, CSRF, ограничение попыток ввода пароля и throttle.
API раскладывается по файлам, app/api/todos/[id].php отвечает на запрос /api/todos/5:
return [ 'middleware' => ['auth'], 'GET' => fn (int $id) => Todo::findOrFail($id), 'PATCH' => function (int $id, Request $request) { $todo = Todo::findOrFail($id); $todo->update($request->validate(['title' => 'required|min:3'])); return $todo; // модель уйдёт как JSON }, ];
Файл можно загрузить прямо в свойство компонента:
<input type="file" accept="image/*" r-model="photo"> <img r-if="photo.preview" :src="photo.preview">
public ?UploadedFile $photo = null; #[Action] public function save(): void { $this->validate(['photo' => 'required|image|max:2048']); $this->user['avatar'] = $this->photo->store('avatars'); }
Файл отправляется на сервер сразу после выбора, поэтому превью видно ещё до сохранения. Ссылка на временный файл подписана, тип проверяется по содержимому.
Для фоновой работы есть очередь, почта и расписание:
Queue::push(SendWelcome::class, ['email' => $email]); Mail::to($email) ->subject('Заказ оформлен') ->view('order', $data) ->queue(); Schedule::call(fn () => Report::send(), 'отчёт') ->dailyAt('09:00') ->weekdays();
Очередь хранится в файлах, Redis не нужен. Почту отправляем через SMTP, а в разработке просто пишем в журнал. Очередь и расписание в режиме разработки крутит rig dev.
Кратко про остальное: .env, журнал, кеш данных, функции перевода t() с формами множественного числа (в PHP и в шаблоне работают одинаково), теги <head> и Open Graph для каждой страницы, анимации r-transition, debounce и throttle для событий и статическая сборка rig generate.
Разработка: одна команда
$ rig dev rig v0.6.0 · PHP 8.4.19 · dev ✔ Окружение (PHP 8.4.19) 0 ms ✔ База данных PostgreSQL 18.3 · порт 54329 1.02 s ✔ Миграции (новых нет) 48 ms ✔ Компоненты (18) 4 ms ✔ Хаб: HMR и события с сервера 18 ms ✔ Сервер приложения 192 ms ➜ Local: http://127.0.0.1:8000/ ➜ База: pgsql://127.0.0.1:54329/rig
От системы нужен только PHP 8.1 или новее. rig dev поднимает сервер, несколько воркеров, базу, хаб событий, обработчик очереди и планировщик. Если меняете шаблон, стиль или PHP-код компонента, правка применяется сразу, без перезагрузки, и состояние сохраняется: введённый текст остаётся в полях, открытые панели не закрываются.
rig check собирает все компоненты и запускает тесты фреймворка. Сейчас их 327, бэкенд проверяется и на SQLite, и на PostgreSQL. rig build готовит выкладку и останавливает её, если в шаблоне ошибка или на продакшене забыли выключить отладку.
Что даёт такой подход
Валидацию, права и бизнес-правила пишут один раз на PHP. Отдельный API-слой, который нужен только своему же фронтенду, не появляется. Нет и сборщика фронтенда: ни Node, ни бандлера, ни node_modules. Компоненты собираются сами при первом запросе, а rig build перед выкладкой прогревает кеш и проверяет шаблоны и настройки. Первая загрузка отдаёт готовый HTML, это быстро для пользователя и удобно для поисковиков.
Там, где важен отклик, задержки нет: #[Client], выражения #[Computed] и optimistic-обновления работают без обращения к сети. При этом серверные данные остаются под контролем PHP.
С безопасностью ничего настраивать не нужно. Состояние подписано, свойства с #[Locked] и #[Prop] из браузера не изменить, вызвать можно только методы с #[Action]. CSRF и проверки доступа включены по умолчанию. Ошибку в шаблоне компилятор найдёт раньше, чем её увидит пользователь.
Для меня главное вот что: чтобы сделать интерфейс, бэкендеру хватает PHP, и не нужно переключаться на другой стек.
Ограничения
Каждое серверное действие означает запрос, и на медленной сети это заметно. Optimistic-обновления, #[Client], #[Computed] и debounce немного сглаживают, но интерфейс, который должен работать офлайн, или тяжёлый редактор я бы всё равно делал в браузере. Для отдельного виджета хватает r-js.
Проект ещё молодой. Готовых компонентов от сообщества нет, нет версий по semver, в продакшене пока нет реальных внедрений. Переводить большой живой проект на rig я пока не стал бы.
Хаб событий работает внутри одного сервера. Для нескольких серверов нужен Redis pub/sub, он в планах.
Тестами покрыты язык выражений, бэкенд и директивы шаблонов. Автотестов рантайма в браузере и CI пока нет. Нет и расширения для редактора с подсказками в шаблонах, и браузерных devtools.
Кому подойдёт
PHP-командам, которым нужен живой интерфейс без отдельного фронтенда: админки, личные кабинеты, внутренние системы, CRM, дашборды.
Сайтам, где важны SEO и быстрая первая загрузка, но без интерактивности не обойтись: каталоги, фильтры, корзины.
Небольшим командам и тем, кто пишет в одиночку и не хочет тянуть второй стек.
Попробовать
Код лежит на GitHub: github.com/msdoc11/rig.
git clone https://github.com/msdoc11/rig.git cd rig cp .env.example .env # настройки: база PostgreSQL проекта, почта в журнал ./rig.sh install # один раз: команда rig в любом терминале (PowerShell: .\rig install) rig dev # база, сервер, HMR и очередь сразу
В репозитории есть документация на 24 раздела (/docs) и демо (/demo):
счётчик;
задачи в PostgreSQL с событиями между вкладками;
ленивый каталог;
вход и кабинет с загрузкой аватара;
страница с
r-modelна компонентах, общей корзиной, анимациями, графиком наr-jsи переключением языка.
Что дальше
В ближайших планах:
тесты компонентов приложения:
Rig::test(Counter::class)->call('increment')->assertSet('count', 1);расширение для VS Code с подсказками свойств и действий в шаблонах;
devtools в браузере;
хаб на Redis;
генераторы
rig make:*;документация на английском.
Пишите в комментариях, какие задачи вы бы решали на rig и где, по-вашему, такой подход не сработает.
Комментарии (4)

mityukov
20.10.2026 13:50Практически Livewire :)

msdoc11 Автор
20.10.2026 13:50Да, идея похожая: компонент живёт на сервере, а его состояние хранится на странице в подписанном снимке. Разница в шаблонах. Выражения в них написаны на маленьком языке, который понимают и PHP, и браузер, так что
#[Computed('count * 2')], optimistic-обновления и состояние#[Client]считаются прямо в браузере, без похода на сервер.
galtsevt
А жадную загрузку (eager loading) связей моделей как делаете? И как без неё N+1 не убивает? Смотрел только Query и Model — там всё смешано: пагинация, гидратация, работа и с моделями, и с сырыми массивами, HttpError из Query.
msdoc11 Автор
Спасибо, что посмотрели код, замечания справедливые. Жадной загрузки действительно не было, сегодня добавил
with():Это один запрос
whereInна каждую связь, то есть четыре запроса на любое число постов. Условия тоже передаются:with(['comments' => fn ($q) => $q->where('hidden', false)]). Загруженная связь читается свойством$post->authorи попадает в состояние компонента вложенным массивом. Проверить, что N+1 не вернулся, можно черезDB::connection()->queries.Про смешение тоже поправил.
findOrFail()иfirstOrFail()теперь бросаютRecordNotFound, а в 404 его превращает обработчик запроса.paginate()отдаёт то же, чтоget(): модели для модели и массивы дляDB::table().