Во время рефакторинга интеграции с iiko (woo2iiko) я снова столкнулся с классической проблемой WordPress: как по-человечески организовать настройки в крупном плагине.
WordPress исторически провоцирует писать процедурный код: функции get_option() и update_option() доступны глобально из любого места. Пока проект маленький, проблем нет: сохранить пару булевых флагов или текстовый ключ несложно. Но когда плагин разрастается до десятков модулей (календарь слотов доставки, бонусная система, стоп-листы, синхронизация модификаторов, кастомные поля чекаута), без четкой структуры работа с опциями быстро превращается в хаос: вы либо часами вылавливаете опечатки в строковых ключах, либо ловите баги из-за несовпадения типов.
$rest_time = get_option('iiko_rest_time', '00:30');
$enabled = get_option('iiko_enable_calendar', 'yes');
$interval = get_option('iiko_time_slot_interval', 30);
Проблема не только в опечатках вроде 'iiko_rest_tiem', которые PHPStan просто не заметит. Главная беда — полное отсутствие типизации: get_option() всегда возвращает mixed. Значение из базы может прийти строкой "0", числом 0, булевым false или вообще строкой "no" (стандартный чекбокс WooCommerce). В итоге бизнес-логика обрастает костылями с приведением типов при каждом обращении.
Попытка решить в лоб: глобальный класс констант
Первое, что часто делают в таких случаях — выносят ключи в константы одного общего класса:
namespace iiko\Shared\Settings;
final class SettingsKey
{
public const REST_TIME = 'iiko_rest_time';
public const ENABLE_CALENDAR = 'iiko_enable_calendar';
public const TIME_SLOT_INTERVAL = 'iiko_time_slot_interval';
public const BONUS_PAYMENT_ID = 'iiko_bonus_payment_id';
// ... и еще 150 констант со всего проекта
}
Это создает иллюзию порядка: IDE теперь подсказывает константы через автокомплит. Но по сути это архитектурный тупик:
- Нарушение модульности: изолированный модуль календаря доставки внезапно зависит от глобального God Object из пространства
Shared, куда свалены ключи всех модулей проекта. - Типизации по-прежнему нет: вызов
$store->get(SettingsKey::REST_TIME)всё так же возвращаетmixed. - Дефолты размазаны по коду: значение по умолчанию
'00:30'зашито в одном месте при вызове, а в массиве полей настроек админки — в другом. Изменили в одном месте, забыли в другом — получили скрытый баг.
Почему стандартные паттерны не подошли
Прежде чем писать свой генератор, я протестировал типовые подходы из Laravel, Symfony и DDD.
1. Scoped Dot-Notation (а-ля Laravel config)
Разложить настройки по файлам модулей и читать через точечную нотацию ($settings->get('delivery_calendar.rest_time')). Структурно это чище, но под капотом мы меняем одну строку на другую. PHPStan по-прежнему слеп к опечаткам внутри строки, а возвращаемый тип — всё тот же mixed.
$restTime = $settings->get('delivery_calendar.rest_time', default: '00:30');
2. DTO с PHP 8 Attributes (подход Spatie)
Описать настройки как readonly class, а метаданные для админки повесить через атрибуты:
final readonly class DeliveryCalendarConfig
{
public function __construct(
#[UIField(title: 'Время на сборку', type: 'text')]
public string $rest_time = '00:30',
#[UIField(title: 'Включить календарь', type: 'checkbox')]
public bool $enabled = true,
) {}
}
Выглядит красиво, но в WordPress этот подход разбивается об ограничения языка. Описания полей в WooCommerce требуют динамики: вызовов функций перевода __('Заголовок', 'iiko'), подстановок sprintf() и вызовов get_option(). В атрибутах PHP запрещены вызовы функций и динамические выражения: их аргументы обязаны быть строгими константами времени компиляции.
3. Паттерн Repository / Data Access Object с типизированными методами
Сделать под каждый модуль класс-обертку со строгими геттерами:
class DeliveryCalendarSettings
{
public function __construct(private SettingsStore $store) {}
public function restTime(): string
{
return (string) $this->store->get('iiko_rest_time', '00:30');
}
public function isEnabled(): bool
{
return (bool) $this->store->get('iiko_enable_calendar', true);
}
}
Здесь есть автокомплит и типы. Но в реальности вылезает главная проблема: рассинхронизация дефолтов. Дефолтное значение '00:30' зашито в методе restTime(), но WooCommerce для отрисовки страницы настроек требует отдельный массив полей, где этот дефолт нужно прописать второй раз. Рано или поздно дефолт изменят в форме админки, но забудут поправить в геттере. Плюс необходимость руками писать десятки тривиальных методов на каждую новую опцию.
Сводная матрица архитектурных решений
| Подход | Автокомплит | Типизация | Поддержка i18n WP | Риск рассинхрона дефолтов |
|---|---|---|---|---|
| Глобальный SettingsKey | Только имя ключа | Нет (mixed) |
Нативная | Высокий |
| Dot-Notation | Нет | Нет (mixed) |
Нативная | Высокий |
| PHP 8 Attributes | 100% | Строгая | Несовместимо (нет функций) | Низкий |
| DAO с явными методами | 100% | Строгая | Нативная | Высокий (дублирование) |
| Schema-First (JSON) | 100% | Строгая | Нативная (через генератор) | Нулевой (Single Source of Truth) |
Наше решение: Schema-First с кодогенерацией
В итоге мы остановились на проверенной концепции: один файл settings.json на уровне модуля является единственным источником правды. Из него консольная команда собирает два специализированных класса:
- Строго типизированный
readonly DTOдля бизнес-логики в рантайме. - Массив UI-схемы для админки WooCommerce с готовыми функциями перевода
__().
Почему именно JSON, а не YAML?
- Нулевые зависимости: парсер
json_decode()встроен прямо в ядро PHP. Не нужны пакеты вродеsymfony/yamlили сторонние C-расширения. - Строгая типизация без сюрпризов: в YAML есть неявные приведения (классическая проблема, когда код страны Норвегии
NOпревращается в булевfalse). В JSON тип строго определен. - Нативная поддержка в IDE: PhpStorm и VS Code валидируют JSON и подсвечивают синтаксис из коробки.
Как это устроено на практике
Шаг 1. Описываем схему модуля (settings.json)
Файл лежит прямо в папке фичи: src/Features/DeliveryCalendar/settings.json. Формат полей на 100% совместим с WooCommerce Settings API:
[
{
"type": "title",
"title": "Модуль календаря доставки",
"desc": "Настройки выбора даты и времени доставки."
},
{
"id": "iiko_enable_calendar",
"property": "enable_calendar",
"php_type": "bool",
"type": "checkbox",
"title": "Включить выбор времени",
"desc": "Добавляет выбор времени доставки на чекауте",
"default": "yes"
},
{
"id": "iiko_rest_time",
"property": "rest_time",
"php_type": "string",
"type": "text",
"title": "Время на подготовку заказа",
"desc_tip": "Минимальный интервал до ближайшего доступного времени доставки",
"default": "00:30"
},
{
"id": "iiko_time_slot_duration",
"property": "time_slot_duration",
"php_type": "int",
"type": "number",
"title": "Шаг слота времени (мин)",
"default": 30
}
]
Шаг 2. Запускаем генератор (composer generate:settings)
Команда сканирует папки модулей src/Features/*/settings.json и компилирует два файла в директорию Generated/.
1. Строго типизированный DTO (DeliveryCalendarConfig.php):
<?php
declare(strict_types=1);
namespace iiko\Features\DeliveryCalendar\Generated;
use iiko\Shared\Settings\SettingsStore;
/**
* AUTO-GENERATED FILE FROM settings.json. DO NOT EDIT DIRECTLY.
*/
final readonly class DeliveryCalendarConfig
{
public function __construct(
public bool $enable_calendar = true,
public string $rest_time = '00:30',
public int $time_slot_duration = 30,
) {}
public static function fromStore(SettingsStore $store): self
{
return new self(
enable_calendar: (bool) $store->get('iiko_enable_calendar', true),
rest_time: (string) $store->get('iiko_rest_time', '00:30'),
time_slot_duration: (int) $store->get('iiko_time_slot_duration', 30),
);
}
}
2. UI-схема для WooCommerce (DeliveryCalendarUiSchema.php):
<?php
declare(strict_types=1);
namespace iiko\Features\DeliveryCalendar\Generated;
/**
* AUTO-GENERATED FILE FROM settings.json. DO NOT EDIT DIRECTLY.
*/
final class DeliveryCalendarUiSchema
{
public static function fields(): array
{
return [
[
'type' => 'title',
'title' => __('Модуль календаря доставки', 'iiko'),
'desc' => __('Настройки выбора даты и времени доставки.', 'iiko'),
],
[
'id' => 'iiko_enable_calendar',
'type' => 'checkbox',
'title' => __('Включить выбор времени', 'iiko'),
'desc' => __('Добавляет выбор времени доставки на чекауте', 'iiko'),
'default' => 'yes',
],
[
'id' => 'iiko_rest_time',
'type' => 'text',
'title' => __('Время на подготовку заказа', 'iiko'),
'desc_tip' => __('Минимальный интервал до ближайшего доступного времени доставки', 'iiko'),
'default' => '00:30',
],
[
'id' => 'iiko_time_slot_duration',
'type' => 'number',
'title' => __('Шаг слота времени (мин)', 'iiko'),
'default' => 30,
],
];
}
}
Что это дало в реальном проекте
- Чистая бизнес-логика: сервисы модуля получают в конструктор готовый
DeliveryCalendarConfig. Они ничего не знают про WooCommerce, базу данных или админку. В коде нет ни одногоget_option(). - Автокомплит и строгий анализ: PhpStorm моментально подсказывает свойства
$config->rest_time, а PHPStan на 8 уровне доволен отсутствием нетипизированногоmixed. - Честная локализация (i18n): генератор строит нативные вызовы
__(), поэтому утилиты вродеwp i18n make-potи Poedit парсят строки интерфейса стандартным образом. - Быстрые тесты без базы: чтобы протестировать расчет интервалов доставки, не нужно поднимать WordPress, ставить плагины или мокать функции:
public function test_calculates_slots_correctly(): void { $config = new DeliveryCalendarConfig( enable_calendar: true, rest_time: '01:00', time_slot_duration: 30 ); $service = new CalendarSlotCalculator($config); $slots = $service->calculateForDay(new DateTimeImmutable('2026-10-15')); $this->assertCount(16, $slots); }Тест выполняется за 1 миллисекунду на чистом PHP-объекте.
- Страховка в CI/CD: в пайплайн GitHub Actions добавляется элементарная проверка:
composer generate:settings git diff --exit-codeЕсли разработчик изменил
settings.json, но забыл перегенерировать PHP-классы перед коммитом, билд сразу упадет с ошибкой.
Резюме
Вместо попыток выжать невозможное из встроенных возможностей WordPress или превращать модели в нагромождение атрибутов, разделение схемы и рантайма решило задачу проще всего:
- Единый источник правды в
settings.jsonисключает рассинхрон дефолтов. - Бизнес-логика работает только со строгим DTO.
- Админка WooCommerce получает нативный массив со всеми переводами.
- В рантайме нет оверхеда: выполняются обычные скомпилированные PHP-файлы, которые штатно кэшируются в OPcache.