Может показаться, что старый Yii1 в 2026 году уже никому не нужен. Но, на удивление, проектов, застрявших на этом фреймворке, довольно много. Чаще всего сдерживающими факторами от переезда являются размер проекта, какие‑то свои надстройки над фреймворком или отсутствие денег у бизнеса. Переезд сопряжён с рисками получить два проекта на годы вперёд. Параллельно требуются какие‑либо доработки, что требует вдвое больше ресурсов и от разработчиков, и от бизнеса.
Последний год я активно изучаю устройство современных PHP‑фреймворков и PSR‑стандарты. Относительно недавно мне пришла мысль: а что ценного есть в Yii1 и что блокирует возможность быстро съехать на другой фреймворк? Ответ довольно банален — это Active Record. Можно сказать, что это сердце фреймворка. Почему же так получилось? Как ни странно, внешний API данного слоя довольно неплох даже в 2026 году, не говоря уже о 2008-м, когда он появился на свет, а скорее всего, намного раньше.
Что же мы имеем из возможностей данной ORM?
Отношения (Relations) — здесь есть всё необходимое:
HAS_MANY,HAS_ONE,BELONGS_TO,MANY_MANY, а также возможность строить цепочки черезthrough. Для ORM начала нулевых — это был настоящий прорыв.Жадная загрузка отношений через
with— используется как сJOIN, так и сWHERE IN. Базовый, но надёжный функционал для любой ORM.Scopes — переиспользование бизнес‑логики для запросов. Удобно, когда одно и то же условие нужно в разных местах.
Примитивный query builder, скрывающийся за
CDbCommand. Для типовых задач — хватает, для сложных — уже не очень.CDbCriteria — пожалуй, самый противоречивый элемент. С одной стороны, он гибок и позволяет строить сложные условия. С другой — генерирует SQL‑строки без привязки к конкретной СУБД, что в современных реалиях выглядит архаично.
Миграции — и они тоже есть. Без них в серьёзном проекте никуда.
Казалось бы, для ORM из 2008 года вполне недурно. Хотя часть функционала появилась уже в последующих версиях 1.1.x. К сожалению, у меня не было возможности следить за жизнью фреймворка в тот момент — я был занят более важными делами, просиживая штаны за школьной партой.
Переписать всю логику приложения или переписать слой инфраструктуры?
Мы уже выяснили, что фреймворк и в 2026 году путём нехитрых доработок напильником выполняет свою роль рабочей лошадки для бизнеса. Возникает логичный вопрос: а стоит ли рисковать самым ценным, что есть у бизнеса, — данными — и гнаться за современными фреймворками?
А может быть, просто распилить самое ценное из фреймворка на отдельные пакеты?
Что же отвечает за данные? А за них как раз ответственен наш выше упомянутый Active Record, ну и частично его неотъемлемая часть — валидатор. В теории, если извлечь это добро, можно запустить приложение на любом другом фреймворке без больших издержек.
Извлекаем Validator в отдельный пакет
Хотелось мне извлечь сразу Active Record, но я быстро понял: без валидатора это бессмысленная затея. Посмотрел код и осознал — с этим надо что‑то делать. Кому важно, что под капотом? Главное — привычный массив правил и возможность писать свои валидаторы. Спойлер: валидаторов больше нет. Валидатор остался один, а всё остальное стало правилами.
Немного кода.
Интерфейс валидатора:
<?php namespace Yii1x\Validator\Contracts; interface ValidatorInterface { public function validate(string $scenario): bool; public function addError(string $attribute, string $message, array $params = []): static; public function hasErrors(null|string|array $attribute = null): bool; public function getErrors(null|string|array $attribute = null): array; public function clearErrors(null|string|array $attribute = null): static; public function getSafeAttributes(?string $scenario = null): array; public function getRequiredAttributes(?string $scenario = null): array; public function setRules(array $rules): static; }
Один абстрактный класс для правил:
<?php namespace Yii1x\Validator\Rules; use Yii1x\Validator\Validator; abstract class AbstractRule { public Validator $validator; public array $attributes = []; public ?string $message = null; public bool $skipOnError = false; public array $on = []; public array $except = []; public bool $safe = true; abstract protected function validateAttribute(object $object, string $attribute): void; public function validate(object $object, ?array $attributes = null): void; public function applyTo(object $object, string $scenario); protected function isEmpty(mixed $value, bool $trim = false); }
Остальное просто слизал под копирку, удалил JS‑код, и правила, так или иначе связанные с Active Record, не попали в пакет валидатора. Собственно, с кодом пакета можно ознакомиться на GitHub.
Важное отличие от оригинала: теперь валидатор не привязан к Active Record. Он получает на вход любой объект и массив правил, а дальше делает свою магию. Хочешь — валидируй DTO, хочешь — обычный stdClass, хочешь — форму. Это делает его универсальным инструментом для любых проектов, а не только для тех, где есть AR.
Извлекаем Active Record в отдельный пакет
Как только из фреймворка была вынесена единственная зависимость для будущего пакета Active Record, можно приступать к самому интересному.
Под капотом Active Record обнаружился настоящий археологический слой — код писался в эпоху PHP 5, когда многие современные практики еще не сложились. Связи между классами размазаны по всему коду, нет четких контрактов, глобальное состояние пронизывает всё.
Но мы ведь помним: на дворе 2008 год. Поэтому я скопировал нужные файлы и методично прошелся по каждой строке, приводя код к PHP 8.4. Копирайты авторов сохранены — это дань уважения оригинальному фреймворку.
Как быть с глобальным состоянием?
Самый большой вопрос: чем заменить Yii::app()? Правильный ответ — инъекция зависимостей. Но если прокидывать в каждую модель все зависимости отдельно — мы сломаем внешний API, и переход станет таким же сложным, как переезд на другой фреймворк.
Было выбрано компромиссное решение.
ORMContext — прослойка для зависимостей
ORMContext — это статическая прослойка между PSR‑контейнером и AR. Она хранит ссылку на контейнер и предоставляет доступ к зависимостям через статические методы.
<?php namespace Yii1x\ActiveRecord; use Psr\Container\ContainerInterface; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; final class ORMContext { protected static ?ContainerInterface $container = null; protected static bool $debug = false; protected static bool $profile = false; /** * Initialize the global ORM context. * * @param ContainerInterface $container PSR-11 container that provides * CacheInterface and LoggerInterface. * @param bool $debug Enable/disable debug logging. */ public static function bootstrap(ContainerInterface $container, bool $debug = false, bool $profile = false): void; public static function isDebug(): bool; public static function isProfile(): bool; public static function container(): ?ContainerInterface; public static function db(string $name); public static function cache(): ?CacheInterface; // PSR-16 public static function log(): ?LoggerInterface; // PSR-3 public static function dispatch(object $event): void; // PSR-14 }
Что это дает:
Сохранен старый API моделей (
Yii::app()->dbзаменен наORMContext::db())Все зависимости теперь приходят из PSR‑контейнера
Код стал тестируемым
Да, глобальное состояние осталось — это минус. Но зато мы не сломали контракты и отвязались от фреймворка за пару часов. Компромисс, который работает.
QueryBuilder — как же без него?
Сложно представить современную ORM без query билдера. CdbCriteria — это, конечно, неплохо, но у него нет контекста самой модели и её отношений. Проблемой, наверное, является то, что в CDbCriteria отсутствует AST дерево запросов. Было бы неплохо строить абстракцию, которая затем превращалась бы в конкретный SQL‑диалект, а может, и вовсе не SQL. Но, как говорится, что имеем — с тем пока и работаем. QueryBuilder на входе получает объект ActiveRecord и DbCriteria (по необходимости), построение условий делегирует ConditionBuilder, и мы получаем следующий синтаксис:
$posts = Post::queryBuilder() ->select(['id', 'title', 'created_at', 'view_count']) ->where('status', 'published') ->whereBetween('created_at', '2024-01-01', '2026-12-31') ->where(function (ConditionBuilder $cb) { $cb->where('is_featured', 1) ->where('view_count', '>', 1000, operator: 'OR'); }) ->whereRelation('comments', function (ConditionBuilder $cb) { $cb->where('status', 'approved') ->where('rating', '>=', 4); }) ->whereRelation('tags', function (ConditionBuilder $cb) { $cb->whereIn('name', ['laravel', 'php', 'yii']); }) ->with(['comments', 'tags', 'author.profile']) ->orderBy('view_count DESC') ->limit(10) ->findAll();
Подробности в репозитории на Гитхабе.
Из плохого не написал тесты для пакета active record, каюсь в процессе.
Собственно, сам пакет на Гитхабе.
Пути модернизации Active Record
После миграции на новый фреймворк мы ведь с вами захотим больше возможностей для нашей Active Record. Тут два пути.
Первый — выдавливать его полностью, но для больших проектов это может занять годы с мучительным поиском альтернативы. Второй — модернизировать пакет, не ломая обратную совместимость. Я вижу как минимум два направления.
1. Отношения — инкапсулировать логику
Задел присутствует, но сейчас логика работы с каждым типом отношений размазана по Active Record, ActiveFinder и прочим классам. Хорошо бы собрать эту логику в уже существующие отдельные классы, спрятав детали реализации за общим контрактом:
interface RelationInterface { public function applyCondition(DbCriteriaInterface $criteria): void; public function applyJoinCondition(DbCriteriaInterface $criteria): void; }
Это позволит:
Добавлять новые типы отношений без правки ядра
Переиспользовать логику между моделями
Тестировать каждый тип изолированно
2. Строка запроса — уйти от привязки к драйверу
Сейчас CDbCriteria формирует SQL‑строку на месте, что жёстко привязывает нас к конкретной СУБД. Чтобы это исправить, нужно ввести контракт:
interface DbCriteriaInterface { public function getConditions(): array; public function getOrder(): array; public function getLimit(): ?int; // ... }
Старую реализацию CDbCriteria оставить для обратной совместимости, а новую — например, NDbCriteria — строить AST‑дерево запроса. Драйвер БД уже сам будет решать, как превратить это дерево в конкретный SQL‑диалект (или вообще в не‑SQL).
Возможно, с этим подходом стоит идти глубже — к CDbCommand. Но сейчас сложно оценить всё верно, не погружаясь в детали реализации драйверов.
Демо приложение
Ну и куда же без демо? Каждый разработчик — насколько бы ему ни был интересен код — явно хочет посмотреть на своё творение в работе.
В качестве демо у нас небольшое приложение на Yii3, использующее Inertia.js и Vue.js. Демо на GitHub. И, конечно же, я не хочу вас утруждать установкой: https://demo.yiiex.ru.
Данные для входа:
Email:
yiiex@yiiex.ru/ Пароль:Admin123456Email:
demo@yiiex.ru/ Пароль:Demo123456
Заключение
Можно подумать, что статья странно оборвалась, не раскрыв всех подробностей. Но поверьте, их там столько, что хватило бы на три книги. Я показал суть, а за деталями — добро пожаловать в репозитории.
Главный вывод однозначен: Active Record из Yii1 способен жить в современном приложении отдельно от фреймворка. И иногда не стоит сломя голову гнаться за модными фреймворками — денег бизнесу это точно не сэкономит, а рисков — хоть отбавляй.

