Rig: Как я сделал реактивный интерфейс на PHP без Node и сборщика
Привет, Хабр! Пишу свою первую статью. Уже больше десяти лет работаю с 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 и где, по-вашему, такой подход не сработает.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.