Screen в Orchid — это не контроллер и не страница в привычном смысле. Это описание: какие данные нужны, из каких блоков состоит экран, какие действия доступны. Понимание этого разделения определяет, будет ли админка поддерживаемой или превратится в набор экранов на тысячу строк.
Разберём составные части и границу между ними.
Чем Screen отличается от контроллера
Screen — это класс, описывающий страницу админки:
class MyScreen extends Screen
{
// Данные для экрана
public function query(): iterable { }
// Кнопки в header
public function commandBar(): iterable { }
// Структура страницы
public function layout(): iterable { }
}Screen vs Controller: Screen объединяет логику контроллера и view в один файл. Меньше файлов, меньше boilerplate.
Как загружаются данные в Screen
public function query(Order $order): iterable
{
return [
// Простые данные
'order' => $order->load(['items.product', 'user']),
// Коллекции для таблиц
'items' => $order->items,
// Метрики
'metrics' => [
'total' => $order->total,
'items_count' => $order->items->count(),
],
// Графики
'sales_chart' => Order::query()
->selectRaw('DATE(created_at) as date, SUM(total) as total')
->groupBy('date')
->orderBy('date')
->limit(30)
->get(),
];
}query() — единственное место, где загружаются данные, и оно выполняется при каждом открытии экрана. Отсюда два практических следствия.
Первое: все запросы экрана видны в одном методе — это удобно для оптимизации, но и вся стоимость экрана сосредоточена здесь. Забытый with() для связей превращается в N+1 при отрисовке таблицы, а несколько агрегатов подряд — в несколько полных сканирований.
Второе: не кладите в query() то, что нужно не всегда. Данные для вкладки, которую открывают раз в месяц, всё равно будут загружаться при каждом заходе на экран. Для таких случаев есть асинхронные модальные окна и отложенная загрузка.
Layouts — строительные блоки
Rows — вертикальный список полей
use Orchid\Support\Facades\Layout;
use Orchid\Screen\Fields\Input;
use Orchid\Screen\Fields\Select;
use Orchid\Screen\Fields\DateTimer;
public function layout(): iterable
{
return [
Layout::rows([
Input::make('order.number')
->title('Номер заказа')
->readonly(),
Select::make('order.status')
->title('Статус')
->options([
'pending' => 'Ожидает',
'processing' => 'В обработке',
'shipped' => 'Отправлен',
'delivered' => 'Доставлен',
]),
DateTimer::make('order.shipped_at')
->title('Дата отправки')
->format('Y-m-d'),
]),
];
}Columns — колонки
public function layout(): iterable
{
return [
Layout::columns([
// Левая колонка (8/12)
Layout::rows([
Input::make('post.title')->title('Заголовок'),
Quill::make('post.content')->title('Содержание'),
]),
// Правая колонка (4/12)
Layout::rows([
Switcher::make('post.is_published')->title('Опубликовать'),
DateTimer::make('post.published_at')->title('Дата публикации'),
Select::make('post.category_id')
->fromModel(Category::class, 'name')
->title('Категория'),
]),
]),
];
}Tabs — вкладки
public function layout(): iterable
{
return [
Layout::tabs([
'Основное' => [
Layout::rows([
Input::make('product.name')->title('Название'),
TextArea::make('product.description')->title('Описание'),
]),
],
'Цены' => [
Layout::rows([
Input::make('product.price')->title('Цена')->type('number'),
Input::make('product.old_price')->title('Старая цена')->type('number'),
]),
],
'SEO' => [
Layout::rows([
Input::make('product.meta_title')->title('Meta Title'),
TextArea::make('product.meta_description')->title('Meta Description'),
]),
],
'Склад' => $this->stockLayout(),
]),
];
}
private function stockLayout(): array
{
return [
Layout::rows([
Input::make('product.stock')->title('Остаток')->type('number'),
Input::make('product.sku')->title('Артикул'),
]),
];
}Accordion — аккордеон
public function layout(): iterable
{
return [
Layout::accordion([
'Информация о клиенте' => [
Layout::rows([
Input::make('order.user.name')->title('Имя')->readonly(),
Input::make('order.user.email')->title('Email')->readonly(),
]),
],
'Адрес доставки' => [
Layout::rows([
Input::make('order.address.city')->title('Город'),
Input::make('order.address.street')->title('Улица'),
]),
],
]),
];
}Table — таблицы
use Orchid\Screen\TD;
use Orchid\Screen\Actions\Button;
use Orchid\Screen\Actions\DropDown;
use Orchid\Screen\Actions\Link;
public function layout(): iterable
{
return [
Layout::table('items', [
TD::make('product.name', 'Товар')
->render(fn($item) => $item->product->name),
TD::make('quantity', 'Кол-во')
->align(TD::ALIGN_CENTER),
TD::make('price', 'Цена')
->render(fn($item) => number_format($item->price, 0, ',', ' ') . ' ₽'),
TD::make('total', 'Сумма')
->render(fn($item) => number_format($item->price * $item->quantity, 0, ',', ' ') . ' ₽'),
TD::make('actions', 'Действия')
->align(TD::ALIGN_RIGHT)
->render(fn($item) => DropDown::make()
->icon('options-vertical')
->list([
Link::make('Редактировать')
->route('platform.item.edit', $item)
->icon('pencil'),
Button::make('Удалить')
->method('removeItem', ['item' => $item->id])
->icon('trash')
->confirm('Удалить позицию?'),
])
),
]),
];
}Modals — модальные окна
use Orchid\Screen\Actions\ModalToggle;
public function commandBar(): iterable
{
return [
ModalToggle::make('Добавить позицию')
->modal('addItemModal')
->icon('plus')
->method('addItem'),
];
}
public function layout(): iterable
{
return [
// Основной контент
Layout::table('items', [...]),
// Модальное окно
Layout::modal('addItemModal', [
Layout::rows([
Select::make('product_id')
->fromModel(Product::class, 'name')
->title('Товар')
->required(),
Input::make('quantity')
->title('Количество')
->type('number')
->value(1)
->required(),
]),
])
->title('Добавить позицию')
->applyButton('Добавить'),
];
}
public function addItem(Request $request): void
{
$validated = $request->validate([
'product_id' => 'required|exists:products,id',
'quantity' => 'required|integer|min:1',
]);
// Логика добавления...
Toast::success('Позиция добавлена');
}Async Modal — загрузка данных
public function commandBar(): iterable
{
return [
ModalToggle::make('Просмотр')
->modal('viewModal')
->modalTitle('Детали заказа')
->async('asyncGetOrder'), // Метод для загрузки данных
];
}
public function asyncGetOrder(Order $order): iterable
{
return [
'order' => $order->load('items'),
];
}
public function layout(): iterable
{
return [
Layout::modal('viewModal', [
Layout::rows([
Input::make('order.number')->readonly(),
Input::make('order.total')->readonly(),
]),
])->async('asyncGetOrder'),
];
}Listeners — реактивность
public function layout(): iterable
{
return [
Layout::rows([
Select::make('order.delivery_type')
->options([
'pickup' => 'Самовывоз',
'courier' => 'Курьер',
'post' => 'Почта',
])
->title('Способ доставки'),
// Показываем только для курьера
Input::make('order.address')
->title('Адрес доставки')
->canSee($this->query['order']->delivery_type === 'courier'),
]),
];
}Turbo-обновление при изменении
Layout::rows([
Select::make('category_id')
->fromModel(Category::class, 'name')
->title('Категория'),
// Это поле обновится при смене категории
Relation::make('subcategory_id')
->fromModel(Subcategory::class, 'name')
->applyScope('forCategory', $this->query['category_id'])
->title('Подкатегория'),
])->async('asyncUpdateSubcategories'),Кастомный Layout
// app/Orchid/Layouts/OrderSummaryLayout.php
namespace App\Orchid\Layouts;
use Orchid\Screen\Layouts\Rows;
use Orchid\Screen\Fields\Label;
class OrderSummaryLayout extends Rows
{
protected function fields(): iterable
{
$order = $this->query->get('order');
return [
Label::make('summary')
->title('Итого по заказу')
->value(view('admin.partials.order-summary', compact('order'))),
];
}
}
// Использование
public function layout(): iterable
{
return [
Layout::table('items', [...]),
OrderSummaryLayout::class,
];
}Производительность: Не загружайте много данных в query(). Используйте пагинацию и ленивую загрузку для связей.
Итоги
| Layout | Назначение |
|---|---|
rows | Вертикальный список полей |
columns | Колонки (grid) |
table | Таблица с данными |
tabs | Вкладки |
accordion | Аккордеон |
modal | Модальное окно |
block | Блок с заголовком |
legend | Группировка полей |
Правила, к которым сводится работа с экранами.
Разделение обязанностей соблюдайте буквально. query() — только загрузка данных, layout() — только структура, обработчики — только действия. Бизнес-логика в обработчике экрана — та же ошибка, что логика в контроллере: её нельзя вызвать из консольной команды, из очереди или из другого экрана. Обработчик должен быть тонким: проверить права, разобрать вход, вызвать сервис.
Выносите повторяющиеся layouts в классы. Одинаковая таблица заказов на трёх экранах — это один класс, а не три копии. Копии расходятся через месяц, и расхождение обнаруживает пользователь.
Разбивайте на вкладки, но помните про стоимость. Вкладки организуют интерфейс, а не уменьшают нагрузку: данные всех вкладок загружаются в query() одновременно. Тяжёлой вкладке нужна асинхронная загрузка, а не просто отдельная вкладка.
Не перегружайте один экран. Признак, по которому это видно: query() длиннее тридцати строк или больше пяти блоков верхнего уровня в layout(). Обычно это значит, что экран пытается быть двумя.
Про модальные окна. Асинхронные модальные окна — самый недооценённый инструмент Orchid: они позволяют не тащить данные редко используемых форм в основной запрос экрана. Если экран медленный, а половина его содержимого — формы, которые открывают изредка, перенос их в асинхронные модальные окна даёт больше, чем любая оптимизация запросов.
Частые вопросы
Чем Screen в Orchid отличается от контроллера?
Screen объединяет логику контроллера и представление в одном классе: метод query описывает, какие данные нужны, layout — из каких блоков состоит экран, commandBar — какие действия доступны, а методы-обработчики выполняют эти действия. Меньше файлов и меньше шаблонного кода, но и вся ответственность за экран сосредоточена в одном месте.
Когда выполняется метод query?
При каждом открытии экрана. Это единственное место, где загружаются данные, поэтому в нём сосредоточена вся стоимость экрана. Удобно для оптимизации — все запросы видны сразу; неудобно тем, что любая забытая мелочь умножается на частоту открытий.
Что не стоит класть в query?
То, что нужно не всегда. Данные для вкладки, которую открывают раз в месяц, всё равно будут загружаться при каждом заходе на экран. Для таких случаев есть асинхронные модальные окна, подгружающие содержимое по требованию, и отложенная загрузка отдельных блоков.
Какие layout-блоки есть в Orchid?
Основные: rows — вертикальный список полей, columns — раскладка в колонки, table — таблица с данными, tabs — вкладки, accordion — раскрывающиеся секции, modal — модальное окно. Блоки вкладываются друг в друга, поэтому сложный экран собирается из простых частей декларативно, без вёрстки.
Как избежать N+1 при отрисовке таблицы в Orchid?
Предзагрузкой связей в методе query. Таблица отрисовывает колонку для каждой строки, и обращение к связанной модели внутри колонки без предзагрузки даёт запрос на каждую строку. Ошибка тихая: данные правильные, просто экран открывается в разы дольше.
Зачем нужны асинхронные модальные окна?
Чтобы не грузить данные, которые могут не понадобиться. Обычное модальное окно получает данные из query экрана, то есть платит за них при каждом открытии страницы. Асинхронное запрашивает их только в момент открытия самого окна, отдельным методом. Для редких действий вроде просмотра истории или подтверждения с деталями это принципиальная разница.