Daily MaverickBAIL DENIED: ‘Dangerous pattern’: Shadrack Sibiya denied bail over threats and wiped cellphoneESPNStrength of schedule (SOS) cheat sheet: Every team's ratings for the rest of seasonThe Jerusalem PostHow to vote in Israel: A guide for the first-time voter ahead of the Knesset elections - explainerESPN DeportesChivas enfrenta tercera crisis de resultados en era MilitoInquirerPhilHealth expands access to anti-TB medicines via GAMOTPunchLagos can’t fund Fourth Mainland Bridge alone, says HamzatBollywood HungamaMika Singh DEFENDS Daler Mehndi amid Uttarakhand event controversy, reveals he had a big operation recently: “Have you ever seen a glass of alcohol in his hand”Screen RantFinal Fantasy Resonance Review - Close To Classic Final Fantasy
The Daily Newsstand · Free, Always
Tuesday, October 20, 2026

Rig: Как я сделал реактивный интерфейс на PHP без Node и сборщика

Translate

Привет, Хабр! Пишу свою первую статью. Уже больше десяти лет работаю с 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 />

Вот что происходит, когда нажимают «+»:

  1. Число меняется сразу. Оптимистичное выражение count = count + 1 браузер выполняет сам, не дожидаясь ответа сервера.

  2. ×2 пересчитывается мгновенно: #[Computed('count * 2')] работает и в PHP, и в браузере.

  3. На сервер уходит JSON-запрос: «у этого Counter с текущим состоянием вызови increment()».

  4. PHP исполняет обычный метод класса и возвращает новое состояние.

  5. Браузер меняет только те узлы 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 и где, по-вашему, такой подход не сработает.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.