Загрузка...

Архитектура настроек в PHP: почему константы — тупик, и почему JSON?

Как подружить нативную совместимость с WooCommerce Settings API, 100% автокомплит в PhpStorm и строгую типизацию через Schema-First подход и генерацию DTO.
Архитектура настроек в PHP: почему константы — тупик, и почему JSON?

Во время рефакторинга интеграции с 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 теперь подсказывает константы через автокомплит. Но по сути это архитектурный тупик:

  1. Нарушение модульности: изолированный модуль календаря доставки внезапно зависит от глобального God Object из пространства Shared, куда свалены ключи всех модулей проекта.
  2. Типизации по-прежнему нет: вызов $store->get(SettingsKey::REST_TIME) всё так же возвращает mixed.
  3. Дефолты размазаны по коду: значение по умолчанию '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 на уровне модуля является единственным источником правды. Из него консольная команда собирает два специализированных класса:

  1. Строго типизированный readonly DTO для бизнес-логики в рантайме.
  2. Массив 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,
            ],
        ];
    }
}

Что это дало в реальном проекте

  1. Чистая бизнес-логика: сервисы модуля получают в конструктор готовый DeliveryCalendarConfig. Они ничего не знают про WooCommerce, базу данных или админку. В коде нет ни одного get_option().
  2. Автокомплит и строгий анализ: PhpStorm моментально подсказывает свойства $config->rest_time, а PHPStan на 8 уровне доволен отсутствием нетипизированного mixed.
  3. Честная локализация (i18n): генератор строит нативные вызовы __(), поэтому утилиты вроде wp i18n make-pot и Poedit парсят строки интерфейса стандартным образом.
  4. Быстрые тесты без базы: чтобы протестировать расчет интервалов доставки, не нужно поднимать 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-объекте.

  5. Страховка в CI/CD: в пайплайн GitHub Actions добавляется элементарная проверка:
    composer generate:settings
    git diff --exit-code

    Если разработчик изменил settings.json, но забыл перегенерировать PHP-классы перед коммитом, билд сразу упадет с ошибкой.

Резюме

Вместо попыток выжать невозможное из встроенных возможностей WordPress или превращать модели в нагромождение атрибутов, разделение схемы и рантайма решило задачу проще всего:

  • Единый источник правды в settings.json исключает рассинхрон дефолтов.
  • Бизнес-логика работает только со строгим DTO.
  • Админка WooCommerce получает нативный массив со всеми переводами.
  • В рантайме нет оверхеда: выполняются обычные скомпилированные PHP-файлы, которые штатно кэшируются в OPcache.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *