Главная/Статьи/Screens в Orchid — CRUD без боли

Screens в Orchid — CRUD без боли

Глубокое погружение в Orchid Screens: layouts, modals, tabs, async-данные. Строим сложные интерфейсы декларативно.

ДМ
Дмитрий Мещеряков
📅 24 июня 2026 г.📖 6 мин чтения

Screen в Orchid — это не контроллер и не страница в привычном смысле. Это описание: какие данные нужны, из каких блоков состоит экран, какие действия доступны. Понимание этого разделения определяет, будет ли админка поддерживаемой или превратится в набор экранов на тысячу строк.

Разберём составные части и границу между ними.

Анатомия Screen

Screen — это класс, описывающий страницу админки:

php
class MyScreen extends Screen
{
    // Данные для экрана
    public function query(): iterable { }
    // Кнопки в header
    public function commandBar(): iterable { }
    // Структура страницы
    public function layout(): iterable { }
}
💡 Совет

Screen vs Controller: Screen объединяет логику контроллера и view в один файл. Меньше файлов, меньше boilerplate.

Query — загрузка данных

php
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 — вертикальный список полей

php
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 — колонки

php
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 — вкладки

php
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 — аккордеон

php
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 — таблицы

php
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 — модальные окна

php
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 — загрузка данных

php
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 — реактивность

php
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-обновление при изменении

php
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

php
// 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: они позволяют не тащить данные редко используемых форм в основной запрос экрана. Если экран медленный, а половина его содержимого — формы, которые открывают изредка, перенос их в асинхронные модальные окна даёт больше, чем любая оптимизация запросов.

🚀

Хотите такое же решение?

Настрою окружение под ваш проект, учту специфику инфраструктуры и обучу команду.

Обсудить проект →
Бесплатная консультация · Ответ в течение дня

Комментарии

Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.