Главная/Статьи/Кастомные поля в Orchid — создание своих компонентов

Кастомные поля в Orchid — создание своих компонентов

Встроенных полей не хватает? Создаём свои: color picker, map selector, JSON editor. От простого до сложного с JS-интеграцией.

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

Своё поле в Orchid пишется быстро: класс, наследующий базовое поле, плюс Blade-шаблон. Соблазн начать с него велик — но прежде чем создавать четвёртый файл в app/Orchid/Fields, стоит убедиться, что задача действительно не решается настройкой встроенного.

Разберём три уровня сложности — от простой обёртки до поля с внешней библиотекой — и цену, которую вы платите за каждый.

Встроенные поля Orchid

Orchid предоставляет много готовых полей:

php
use Orchid\Screen\Fields\*;
Input::make('name')           // Текстовое поле
TextArea::make('description') // Многострочное
Select::make('status')        // Выпадающий список
Relation::make('category')    // Связь с моделью
DateTimer::make('date')       // Дата/время
Switcher::make('active')      // Переключатель
CheckBox::make('agree')       // Чекбокс
Radio::make('type')           // Radio-кнопки
Upload::make('files')         // Загрузка файлов
Picture::make('image')        // Изображение
Quill::make('content')        // WYSIWYG редактор
Code::make('code')            // Редактор кода
Matrix::make('data')          // Таблица данных

Но иногда нужно что-то своё.

💡 Совет

Способы расширения:

  1. Наследование от существующего поля
  2. Создание с нуля (Field + Blade)
  3. Интеграция JS-библиотек

Простое поле: Color Picker

Класс поля

php
// app/Orchid/Fields/ColorPicker.php
namespace App\Orchid\Fields;
use Orchid\Screen\Field;
class ColorPicker extends Field
{
    /**
     * Blade-шаблон
     */
    protected $view = 'admin.fields.color-picker';
    /**
     * Атрибуты по умолчанию
     */
    protected $attributes = [
        'class' => 'form-control color-picker',
        'type' => 'color',
    ];
    /**
     * Список разрешённых атрибутов
     */
    protected $inlineAttributes = [
        'name',
        'value',
        'class',
        'style',
        'disabled',
    ];
    /**
     * Предустановленные цвета
     */
    public function colors(array $colors): self
    {
        $this->set('colors', $colors);
        return $this;
    }
    /**
     * Формат вывода (hex, rgb, hsl)
     */
    public function format(string $format): self
    {
        $this->set('format', $format);
        return $this;
    }
}

Blade-шаблон

blade
{{-- resources/views/admin/fields/color-picker.blade.php --}}
@component($typeForm, get_defined_vars())
    <div class="color-picker-wrapper">
        <input
            {{ $attributes }}
            @if(isset($colors))
                data-colors="{{ json_encode($colors) }}"
            @endif
            data-format="{{ $format ?? 'hex' }}"
        >
        @if(isset($colors) && count($colors))
            <div class="color-presets mt-2">
                @foreach($colors as $color)
                    <button 
                        type="button" 
                        class="color-preset-btn"
                        style="background-color: {{ $color }}"
                        data-color="{{ $color }}"
                    ></button>
                @endforeach
            </div>
        @endif
    </div>
@endcomponent
@push('scripts')
<script>
document.querySelectorAll('.color-preset-btn').forEach(btn => {
    btn.addEventListener('click', function() {
        const input = this.closest('.color-picker-wrapper').querySelector('input');
        input.value = this.dataset.color;
        input.dispatchEvent(new Event('change'));
    });
});
</script>
@endpush
@push('styles')
<style>
.color-preset-btn {
    width: 24px;
    height: 24px;
    border: 1px solid #ddd;
    border-radius: 4px;
    cursor: pointer;
    margin-right: 4px;
}
.color-preset-btn:hover {
    transform: scale(1.1);
}
</style>
@endpush

Использование

php
use App\Orchid\Fields\ColorPicker;
Layout::rows([
    ColorPicker::make('settings.primary_color')
        ->title('Основной цвет')
        ->colors(['#3490dc', '#38c172', '#e3342f', '#f6993f'])
        ->format('hex'),
]);
⚠️ Важно

Поле отвечает за ввод, а не за корректность данных. Ограничения в шаблоне — формат, маска, набор допустимых значений — существуют для удобства оператора и обходятся отправкой формы напрямую. Валидация должна быть в Request или в обработчике, как для любого другого поля.

Особенно это касается полей со сложным значением (JSON, координаты, структуры): туда придёт то, что прислали, а не то, что вы нарисовали в интерфейсе.

Поле с JS-библиотекой: Map Selector

Установка библиотеки

bash
npm install leaflet

Класс поля

php
// app/Orchid/Fields/MapSelector.php
namespace App\Orchid\Fields;
use Orchid\Screen\Field;
class MapSelector extends Field
{
    protected $view = 'admin.fields.map-selector';
    protected $attributes = [
        'class' => 'map-selector',
    ];
    protected $inlineAttributes = [
        'name',
        'value',
    ];
    /**
     * Начальные координаты
     */
    public function center(float $lat, float $lng): self
    {
        $this->set('center', ['lat' => $lat, 'lng' => $lng]);
        return $this;
    }
    /**
     * Уровень зума
     */
    public function zoom(int $zoom): self
    {
        $this->set('zoom', $zoom);
        return $this;
    }
    /**
     * Высота карты
     */
    public function height(string $height): self
    {
        $this->set('height', $height);
        return $this;
    }
}

Blade-шаблон

blade
{{-- resources/views/admin/fields/map-selector.blade.php --}}
@component($typeForm, get_defined_vars())
    <div 
        id="map-{{ $name }}" 
        class="map-container"
        style="height: {{ $height ?? '400px' }}"
        data-center="{{ json_encode($center ?? ['lat' => 55.7558, 'lng' => 37.6173]) }}"
        data-zoom="{{ $zoom ?? 12 }}"
    ></div>
    <input 
        type="hidden" 
        name="{{ $name }}" 
        id="input-{{ $name }}"
        value="{{ $value }}"
    >
    <div class="mt-2 small text-muted coordinates-display" id="coords-{{ $name }}">
        Координаты: <span>не выбраны</span>
    </div>
@endcomponent
@push('head')
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
@endpush
@push('scripts')
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
    const mapEl = document.getElementById('map-{{ $name }}');
    const input = document.getElementById('input-{{ $name }}');
    const coordsDisplay = document.querySelector('#coords-{{ $name }} span');
    const center = JSON.parse(mapEl.dataset.center);
    const zoom = parseInt(mapEl.dataset.zoom);
    const map = L.map(mapEl).setView([center.lat, center.lng], zoom);
    L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
        attribution: '© OpenStreetMap'
    }).addTo(map);
    let marker = null;
    // Если есть сохранённое значение
    if (input.value) {
        const coords = JSON.parse(input.value);
        marker = L.marker([coords.lat, coords.lng]).addTo(map);
        coordsDisplay.textContent = `${coords.lat.toFixed(6)}, ${coords.lng.toFixed(6)}`;
    }
    // Клик по карте
    map.on('click', function(e) {
        const lat = e.latlng.lat;
        const lng = e.latlng.lng;
        if (marker) {
            marker.setLatLng(e.latlng);
        } else {
            marker = L.marker(e.latlng).addTo(map);
        }
        input.value = JSON.stringify({ lat, lng });
        coordsDisplay.textContent = `${lat.toFixed(6)}, ${lng.toFixed(6)}`;
    });
});
</script>
@endpush

Использование

php
use App\Orchid\Fields\MapSelector;
Layout::rows([
    MapSelector::make('location.coordinates')
        ->title('Местоположение')
        ->center(55.7558, 37.6173)  // Москва
        ->zoom(12)
        ->height('300px'),
]);

JSON Editor поле

php
// app/Orchid/Fields/JsonEditor.php
namespace App\Orchid\Fields;
use Orchid\Screen\Field;
class JsonEditor extends Field
{
    protected $view = 'admin.fields.json-editor';
    protected $attributes = [
        'class' => 'json-editor',
    ];
    /**
     * Схема JSON (для валидации)
     */
    public function schema(array $schema): self
    {
        $this->set('schema', $schema);
        return $this;
    }
    /**
     * Режим редактора (tree, code, text)
     */
    public function mode(string $mode): self
    {
        $this->set('mode', $mode);
        return $this;
    }
}
blade
{{-- resources/views/admin/fields/json-editor.blade.php --}}
@component($typeForm, get_defined_vars())
    <div 
        id="jsoneditor-{{ $name }}" 
        class="json-editor-container"
        style="height: 400px; border: 1px solid #ced4da; border-radius: 4px;"
    ></div>
    <textarea 
        name="{{ $name }}" 
        id="input-{{ $name }}"
        style="display: none;"
    >{{ $value }}</textarea>
@endcomponent
@push('head')
<link href="https://cdn.jsdelivr.net/npm/jsoneditor@9/dist/jsoneditor.min.css" rel="stylesheet">
@endpush
@push('scripts')
<script src="https://cdn.jsdelivr.net/npm/jsoneditor@9/dist/jsoneditor.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
    const container = document.getElementById('jsoneditor-{{ $name }}');
    const textarea = document.getElementById('input-{{ $name }}');
    const options = {
        mode: '{{ $mode ?? "tree" }}',
        modes: ['tree', 'code', 'text'],
        onChangeText: function(jsonString) {
            textarea.value = jsonString;
        }
    };
    const editor = new JSONEditor(container, options);
    // Загружаем начальные данные
    try {
        const json = JSON.parse(textarea.value || '{}');
        editor.set(json);
    } catch (e) {
        editor.set({});
    }
    // Перед отправкой формы обновляем textarea
    container.closest('form').addEventListener('submit', function() {
        try {
            textarea.value = JSON.stringify(editor.get());
        } catch (e) {}
    });
});
</script>
@endpush

Наследование от существующего поля

php
// app/Orchid/Fields/PhoneInput.php
namespace App\Orchid\Fields;
use Orchid\Screen\Fields\Input;
class PhoneInput extends Input
{
    public function __construct()
    {
        parent::__construct();
        $this->mask('+7 (999) 999-99-99')
            ->title('Телефон')
            ->placeholder('+7 (___) ___-__-__');
    }
    /**
     * Форматирование при получении
     */
    public function value($value): self
    {
        if ($value) {
            // Форматируем номер
            $clean = preg_replace('/\D/', '', $value);
            $value = '+7 (' . substr($clean, 1, 3) . ') ' 
                   . substr($clean, 4, 3) . '-' 
                   . substr($clean, 7, 2) . '-' 
                   . substr($clean, 9, 2);
        }
        return parent::value($value);
    }
}
⚠️ Важно

Совместимость: При обновлении Orchid проверяйте, не изменились ли базовые классы полей. Кастомные поля могут потребовать адаптации.

Итоги

ПодходКогда использовать
НаследованиеНебольшие изменения существующего поля
Field + BladeПолностью кастомный UI
JS-интеграцияСложные компоненты (карты, редакторы)

Структура файлов:

text
app/Orchid/Fields/
├── ColorPicker.php
├── MapSelector.php
├── JsonEditor.php
└── PhoneInput.php
resources/views/admin/fields/
├── color-picker.blade.php
├── map-selector.blade.php
└── json-editor.blade.php

Что учесть до того, как писать своё поле

Каждое собственное поле — это обязательство по сопровождению. Оно опирается на внутреннее устройство Orchid: базовый класс, соглашения шаблонов, способ подключения скриптов. При обновлении пакета всё это может измениться, и обновление превратится из рутинной операции в работу. Четыре собственных поля — четыре потенциальные точки отказа при каждом мажорном обновлении.

Сначала проверьте, не решается ли задача настройкой. Встроенные поля Orchid принимают довольно много параметров, а Input с маской и правилами валидации закрывает больше случаев, чем кажется на первый взгляд. Полноценное своё поле оправдано там, где нужен принципиально другой способ ввода — карта, редактор структуры, выбор цвета, — а не другое оформление.

Наследование лучше написания с нуля. Расширяя существующее поле, вы получаете совместимость с валидацией, обработкой ошибок и внешним видом бесплатно — и меньше кода, который придётся чинить при обновлении.

Проверьте поведение в модальных окнах и в повторяющихся блоках. Поле с инициализацией на DOMContentLoaded прекрасно работает на обычной форме и не работает внутри модального окна, отрисованного позже, — а это тот сценарий, который обязательно встретится.

💡 Совет

И практический совет по внешним библиотекам. Тяжёлая библиотека (карты, редакторы) подключается на всех страницах, где встречается поле, а иногда и на всех страницах админки — в зависимости от того, как вы её добавили. Проверьте, что она загружается только там, где нужна: лишние полмегабайта на каждом экране заметны при ежедневной работе.

🚀

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

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

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

Комментарии

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