Василий Кривопляс
← На главную

PHP · DTO · JSON · Struct

Struct: контракты данных между JSON и PHP

Между JSON и типизированным PHP-объектом можно потерять больше, чем кажется: присутствие поля, точность идентификатора, различие объекта и списка. В Struct 2.0 я сделал эти решения явными — от входного значения до исходящего payload.

Какой контракт нужен на границе данных

Нативное public readonly int-свойство ещё не отвечает на вопросы к внешним данным. Разрешена ли строка с числом? Обязателен ли nullable-параметр? Что делать с неизвестным ключом и двумя именами одного поля? Что уйдёт в следующий API при сериализации? Эти решения легко распределить между контроллером, преобразователем и сериализатором, получив разные правила на входе и выходе.

Struct — небольшая библиотека для PHP 8.1+, которая отображает массивы и JSON в валидируемые DTO и формирует исходящие payload. Поля описываются атрибутами; mapping, validation и export имеют определённый порядок. Бизнес-решения остаются в прикладном коде: библиотека проверяет объявленный контракт данных, а не возможность выполнить операцию с ними.

Дальше — решения версии 2.0: присутствие и null, строгие типы, форма коллекций, входные и выходные имена, копирование. Именно эти детали определяют, можно ли доверять преобразованию на границе интеграции.

Отсутствие поля не равно null

Nullable описывает допустимое значение, обязательность — наличие ключа. Поэтому #[Field] public readonly ?string $comment требует comment во входном payload, но принимает явный null. Отсутствующий ключ и ключ со значением null проверяются отдельно, через array_key_exists.

Поле, не допускающее null, нельзя объявить необязательным без default: объект не должен остаться с неинициализированным свойством. default: null тоже является явно заданным значением по умолчанию. Для различения «default не указан» и «default равен null» используется отдельный маркер DefaultValue::Missing.

Объявление поля Ключ отсутствует Значение null
string $comment Ошибка Ошибка
?string $comment Ошибка Допустимо по типу
?string $comment + required: false Используется null Допустимо по типу
string $comment + default: 'none' Используется 'none' Ошибка
?string $comment + default: null Используется null Допустимо по типу

В таблице подразумевается #[Field]; default и required — параметры атрибута. Validation rules могут дополнительно отклонить null, разрешённый типом. После mapping DTO не хранит, пришёл ли итоговый null из входа или был подставлен: для PATCH, где это различие управляет обновлением, нужно отдельное отслеживание присутствия.

Строгие типы и большие идентификаторы

Mapper проверяет значение до присваивания через Reflection. Строка "2" не принимается как int, а 1 — как bool. Проверяются и mapping type атрибута, и native type свойства. Для float разрешено расширение int → float; преобразование строк в числа задаётся явно через transformer. Молчаливое приведение не должно скрывать нарушение входного контракта.

Отдельная граница — большие JSON-числа. fromJson() всегда включает JSON_BIGINT_AS_STRING: целое за пределами диапазона PHP int становится точной десятичной строкой, а не float с потерянными разрядами. Строгое int-поле такое значение отклонит; string|int сохраняет обычный int или большую строку.

Встроенный mapping также создаёт вложенный Struct из массива или JSON-объекта, backed enum — из значения его backing type, DateTimeImmutable — из строки даты. Для этих случаев пользовательский transformer не нужен. Собственные value objects передаются готовыми экземплярами или создаются явным transformer: Struct не вызывает произвольный конструктор класса с входным массивом.

PHP 8.1+ · идентификатор вне диапазона int
use tommyknocker\struct\Field;
use tommyknocker\struct\Struct;

final class ExternalReference extends Struct
{
    #[Field]
    public readonly string|int $id;
}

$reference = ExternalReference::fromJson('{"id":18446744073709551616}');
// $reference->id === '18446744073709551616'
// $reference->toJson() === '{"id":"18446744073709551616"}'

На выходе большое значение будет JSON-строкой. Это осознанная политика сохранения точности, которую должен принимать следующий API; библиотека не обещает вернуть исходный JSON-тип числа любой величины. В union сначала выбирается точное совпадение типа, затем допустимые преобразования — поэтому строка не должна случайно превратиться в другой вариант union.

Объект и список не должны незаметно меняться местами

json_decode с associative: true превращает и {}, и [] в пустой PHP-массив. Объект с ключами "0" и "1" тоже легко становится списком при следующем json_encode. Для API это может быть сменой типа payload, хотя значения элементов не изменились.

Struct декодирует JSON-объекты как объекты и явно задаёт политику типизированной коллекции. List принимает только последовательный список. Map сохраняет ключи и экспортирует объект, включая пустой. Preserve запоминает, был ли вход JSON-объектом или списком. В следующем примере тип каждого элемента — string; политика коллекции — Preserve.

PHP 8.1+ · пустой объект, пустой список и числовые ключи
use tommyknocker\struct\CollectionType;
use tommyknocker\struct\Field;
use tommyknocker\struct\Struct;

final class Labels extends Struct
{
    #[Field('string', collection: CollectionType::Preserve)]
    public readonly array $labels;
}

$map = Labels::fromJson('{"labels":{}}');
$list = Labels::fromJson('{"labels":[]}');
$numericMap = Labels::fromJson('{"labels":{"0":"a","1":"b"}}');

// $map->toJson()        === '{"labels":{}}'
// $list->toJson()       === '{"labels":[]}'
// $numericMap->toJson() === '{"labels":{"0":"a","1":"b"}}'

toArray() возвращает PHP-массивы и не переносит всю информацию о JSON-форме. Для сохранения этой формы нужен toJson(). Если источник уже PHP-массив, Preserve определяет форму по ключам; [] считается списком. Когда пустой PHP-массив должен уйти объектом, это нужно объявить через Map.

Один порядок для входа, default и validation

Порядок обработки — часть публичного контракта. Сначала проверяются схема модели и входные ключи, затем выбирается входное значение или default. Transformer получает выбранное значение; после mapping применяются правила элементов и всего поля, затем проверяется native type и присваивается readonly-свойство.

Default не является доверенным обходным путём: он тоже проходит преобразования и validation. То же относится к null — тип может разрешать его, а конкретное правило отклонять. Для коллекции itemRules проверяют элементы, validationRules — коллекцию целиком. Требование «хотя бы один элемент» задаётся CountRule, потому что у пустого массива нет элементов для проверки.

Ошибки входа и ошибки модели разделены. Пропуск обязательного поля даёт FieldNotFoundException, нарушение типа, validation rule или политики ключей — ValidationException. LogicException сигнализирует о некорректном объявлении модели. Повреждённый JSON даёт JsonException; список или скаляр в корне корректного JSON — ValidationException, поскольку fromJson() принимает объект. Mapper останавливается на первой ошибке; накопление ошибок для формы нужно организовать в вызывающем коде.

При ошибке вложенного значения путь сохраняется, например items[2].quantity. Transformer может явно отклонить вход через TransformationException; mapper добавляет контекст поля и сохраняет исходную причину. Ошибки программирования и посторонние исключения не превращаются автоматически в ошибки пользовательских данных.

  1. Схема и ключиТипы полей, коллизии имён, политика неизвестных ключей
  2. Входное значение или defaultПрисутствие проверяется отдельно от null
  3. Transformers → mappingЯвная нормализация, типы, вложенные DTO и форма коллекций
  4. Item rules → field rulesПроверки элементов и значения поля целиком
  5. Native type → readonly fieldПроверка PHP-типа перед присваиванием
Порядок построения DTO. Сериализация — отдельный этап с собственным контрактом.

Исходящий payload задаётся явно

alias задаёт альтернативное входное имя; имя свойства тоже принимается, если alias не передан. outputAlias задаёт выходное имя. Одновременная передача alias и имени свойства отклоняется: mapper не выбирает победителя по порядку ключей. Коллизии входных имён и повторяющиеся выходные имена проверяются при построении metadata.

Экспортируются только свойства с #[Field]. Обычное состояние объекта не попадает в payload, как и неизвестные входные ключи, которые DTO проигнорировал. По умолчанию это позволяет принимать добавочные поля от внешнего сервиса; protected const STRICT = true включает отклонение неизвестных ключей для конкретного DTO.

PHP 8.1+ · входное имя, выходное имя и явный набор полей
use tommyknocker\struct\Field;
use tommyknocker\struct\Struct;

final class Message extends Struct
{
    #[Field(alias: 'message_id', outputAlias: 'id')]
    public readonly string $messageId;

    #[Field]
    public readonly string $text;

    public string $debug = 'local';
}

$message = Message::fromJson(
    '{"message_id":"m-7","text":"ready","extra":"upstream"}'
);
// $message->toArray() === ['id' => 'm-7', 'text' => 'ready']

Набор Field — это объявленная проекция, а не автоматическая политика доступа. Если пароль помечен Field, он будет экспортирован. Для входного запроса и публичного ответа с разными полями нужны разные DTO. Аналогично, outputAlias не становится входным alias автоматически: симметрию import/export нужно задать явно.

При экспорте backed enum становится своим backing value, enum без backing type — именем case, а дата — строкой со смещением UTC и микросекундами. Например, DateTimeImmutable не уходит в JSON как объект с внутренними свойствами: его представление задаёт контракт сериализации.

Копия DTO не должна повторно нормализовать старые значения

Повторная сборка DTO из его массива выглядит простым способом сделать копию. Но если transformer неидемпотентен, уже преобразованное значение изменится ещё раз. Например, добавленный к ссылке префикс появится дважды.

with() работает с уже mapped-значениями: все поля валидируются заново, а transformers запускаются только для явных замен. Опечатка в ключе replacements отклоняется даже для DTO с permissive-входом. Форма неизменённых Preserve-коллекций сохраняется.

PHP 8.1+ · неидемпотентное преобразование и копирование
use tommyknocker\struct\Field;
use tommyknocker\struct\Struct;
use tommyknocker\struct\transformation\TransformerInterface;

final class PrefixReference implements TransformerInterface
{
    public function transform(mixed $value): mixed
    {
        return is_string($value) ? 'ref-' . $value : $value;
    }
}

final class Record extends Struct
{
    #[Field(transformers: [new PrefixReference()])]
    public readonly string $reference;

    #[Field]
    public readonly int $attempts;
}

$original = new Record(['reference' => '42', 'attempts' => 0]);
$updated = $original->with(['attempts' => 1]);
// $updated->reference === 'ref-42', not 'ref-ref-42'
// $original->attempts === 0; $updated->attempts === 1

Readonly здесь относится к присваиванию mapped-свойств. Вложенные mutable-объекты могут менять внутреннее состояние, а with() делает неглубокую копию и разделяет такие объекты с оригиналом. Полной глубокой неизменяемости библиотека не обещает; transformers должны избегать мутации переданных объектов.

Граничные случаи как основа тестирования

Проверять только «получили массив — создали объект» недостаточно для этих контрактов. В тестах есть отсутствие и явный null, defaults, неверные native types, большие числа, формы коллекций, коллизии имён и повторное преобразование при with(). Примеры из документации исполняются и проверяют результат, в том числе сценарий преобразования входного заказа партнёра в другой исходящий контракт.

CI настроен на PHP 8.1–8.5; PHPStan работает на уровне 9. Помимо тестов и исполняемых примеров есть проверка установки Composer-дистрибутива и запуска примеров из установленного пакета. Так проверяется и поставка библиотеки, а не только checkout с доступными dev-файлами.

Scaffolding тоже ограничен своим назначением: CLI статически разбирает PHP и добавляет Field-маркеры по типам, без выполнения целевого файла. Он не угадывает validation rules, aliases и defaults по имени свойства. Правило, влияющее на контракт данных, должен задать разработчик.

Когда такой слой оправдан

Struct полезен там, где приложение принимает или отправляет структурированные данные: API, webhooks, очереди, batch import. Он даёт единое место для явных правил mapping и export, без привязки к фреймворку и внешних runtime-зависимостей. Стоимость — описание DTO, освоение API библиотеки и поддержка этих контрактов при изменениях интеграции.

Если существующий mapper и validator уже обеспечивают нужные правила, внедрение ещё одного слоя требует конкретного основания. Проверить выбор можно на собственном payload: как различаются отсутствие и null, сохраняется ли форма JSON, какие поля уходят наружу и что происходит при копировании. Это предметные критерии для оценки небольшой библиотеки.

Установка пакета: composer require tommyknocker/struct.

← На главную