2 · курс
0%

Всё одной страницей

49 разделов подряд — удобно для поиска по Ctrl+F и для печати в PDF.

🚀 Старт

01Что такое Yii 2

Зачем нужен Yii, чем он отличается от других PHP-фреймворков, что нужно знать до старта и как устроен этот курс.

Yii (читается «йи», расшифровывается как Yes It Is) — высокопроизводительный компонентный PHP-фреймворк для быстрой разработки веб-приложений. Ветка 2.0 — полностью переписанный фреймворк на Composer, PSR, пространствах имён и трейтах. Именно о ней весь курс.

Yii в пяти пунктах#

MVC

код организован по паттерну «модель–представление–контроллер», а поверх него — приложение, компоненты, модули, фильтры и виджеты.

Full-stack

из коробки есть Active Record для SQL и NoSQL, построитель запросов, миграции, формы и валидация, RBAC, многоуровневое кеширование, REST API, i18n, консоль, тесты.

Простота вместо догм

фреймворк не заставляет плодить абстракции ради «правильных паттернов»: тонкий контроллер, толстая модель, конфигурация массивами.

Расширяемость

практически любую часть ядра можно настроить или подменить через конфигурацию и DI-контейнер; готовые расширения ставятся Composer'ом.

Производительность

одна из главных целей проекта: ленивое создание компонентов, карта классов, кеширование схемы БД, HTTP-кеширование.

Yii подходит для любых веб-проектов, но особенно раскрывается в крупных: порталы, форумы, CMS, интернет-магазины, RESTful-сервисы — там, где важны компонентная структура и кеширование.

Требования и подготовка#

Что нужно Зачем
PHP 7.4+ (лучше последняя версия)Yii 2 использует современные возможности языка; для отдельных функций требования выше — их покажет requirements.php
Расширение PDO и драйвер вашей СУБД (pdo_mysql, pdo_pgsql…)Любая работа с базой данных
ComposerУстановка фреймворка, расширений и обновлений одной командой
Командная строкаКонсольный скрипт yii: миграции, генерация кода, свои команды

Что стоит знать до старта:

  • ООП в PHP: классы, наследование, интерфейсы, пространства имён и трейты — фреймворк целиком построен на них.
  • Composer: хотя бы раздел Basic usage его документации.
  • Кривая обучения у Yii мягче, чем у большинства PHP-фреймворков: базовое приложение работает сразу после установки, а дальше можно двигаться по темам.

Из чего состоит фреймворк#

Так устроен Yii 2 в общих чертах. Каждый узел — раздел этого курса.

Экосистема#

Как пользоваться курсом#

  1. Идите по порядку. Разделы выстроены как курс: сначала установка и первое приложение, затем архитектура, потом БД, формы, безопасность, кеш, REST и инструменты. В конце каждой страницы — ссылка на следующую.
  2. Ищите по коду. Поиск (Ctrl+K или /) ищет не только по заголовкам, но и по именам классов и методов в примерах: findOne, AccessControl, Url::to.
  3. Проверяйте себя. Внизу разделов — карточки «вопрос → ответ». Попробуйте ответить, прежде чем раскрывать.
  4. Отмечайте прогресс. Галочка «Изучено» сохраняется в браузере; на главной видно, что осталось.
  5. Сверяйтесь с первоисточником. Под каждым разделом перечислены главы руководства, из которых он сжат.
Проверь себя4

«Йи» ([ji:]). В китайском — «простой и эволюционирующий»; также акроним Yes It Is.

2.0 переписана с нуля: Composer, стандарты PSR, пространства имён, трейты. 1.1 — предыдущее поколение в режиме поддержки.

ООП в PHP (включая пространства имён и трейты), Composer и работу в командной строке.

Крупные: порталы, форумы, CMS, магазины, RESTful-приложения — благодаря компонентной структуре и кешированию.

02Установка и запуск

Ставим Yii через Composer, разбираем структуру шаблона basic, настраиваем Apache/Nginx, окружения, Docker и виртуальный хостинг.

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

Установка#

Консоль
# шаблон basic — один сайт, простая структура
composer create-project --prefer-dist yiisoft/yii2-app-basic basic

# шаблон advanced — frontend + backend + console, окружения, регистрация из коробки
composer create-project --prefer-dist yiisoft/yii2-app-advanced advanced
cd advanced && php init          # выбрать окружение (Development / Production)

Composer сам сгенерирует cookieValidationKey. Нестабильную версию можно поставить с --stability=dev, но на боевых серверах так делать не стоит.

  1. Скачайте архив с yiiframework.com/download.
  2. Распакуйте в папку, доступную веб-серверу.
  3. В config/web.php впишите секретный ключ в cookieValidationKey — без него куки не валидируются.

Проверка#

Откройте http://localhost/basic/web/index.php — должна появиться страница «Congratulations!». Если нет, проверьте требования:

Консоль
cd basic && php requirements.php     # или откройте /requirements.php в браузере

Минимум — PHP с расширением PDO и драйвером вашей СУБД. Тестовые данные для входа в шаблоне: admin / admin.

Структура шаблона basic#

basic/
    composer.json       описание проекта и зависимостей
    config/             конфигурация
        web.php         веб-приложение
        console.php     консольное приложение
        db.php          подключение к БД
        params.php      произвольные параметры (Yii::$app->params)
    commands/           консольные команды
    controllers/        контроллеры
    models/             модели
    views/              представления и шаблоны (layouts/)
    web/                корень для веб-сервера — единственная папка, доступная снаружи
        assets/         опубликованные ресурсы (JS/CSS), создаётся автоматически
        index.php       входной скрипт
    runtime/            логи, кеш, временные файлы — должна быть доступна на запись
    vendor/             пакеты Composer, включая сам фреймворк
    yii                 консольный входной скрипт: ./yii help

Два класса файлов: то, что лежит в web/, доступно по HTTP; всё остальное — нет и не должно быть. Именно поэтому корнем сайта делают basic/web, а не basic.

basic
  • одно приложение, один web/;
  • удобен для небольших проектов и обучения;
  • всё в пространстве имён app\.
advanced
  • три приложения: frontend, backend, console, общий код в common;
  • окружения через php init и папку environments/;
  • регистрация, восстановление пароля и миграция пользователей из коробки;
  • псевдонимы @frontend, @backend, @common, @console.

Настройка веб-сервера#

На проде URL https://example.com/basic/web/index.php хочется превратить в https://example.com/. Для этого корень сервера указывают на basic/web, а index.php прячут правилами перезаписи (см. ЧПУ).

nginx
server {
    charset utf-8;
    client_max_body_size 128M;
    listen 80;
    server_name mysite.test;
    root        /path/to/basic/web;
    index       index.php;

    location / {
        # всё, чего нет на диске, отдаём index.php
        try_files $uri $uri/ /index.php$is_args$args;
    }

    location ~ \.php$ {
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_pass 127.0.0.1:9000;      # или unix:/var/run/php-fpm.sock
        try_files $uri =404;
    }

    location ~* /\. { deny all; }
}

В php.ini поставьте cgi.fix_pathinfo=0, а при HTTPS добавьте fastcgi_param HTTPS on;, иначе Yii не поймёт, что соединение защищённое.

Apache
DocumentRoot "/path/to/basic/web"

<Directory "/path/to/basic/web">
    RewriteEngine on
    # существующие файлы и папки отдаём как есть
    RewriteCond %{REQUEST_FILENAME} !-f
    RewriteCond %{REQUEST_FILENAME} !-d
    # остальное — на index.php
    RewriteRule . index.php
</Directory>

Тот же блок правил можно положить в web/.htaccess, если нет доступа к конфигу сервера.

Режимы и окружения#

Входной скрипт web/index.php задаёт две константы до загрузки фреймворка:

PHP
defined('YII_DEBUG') or define('YII_DEBUG', true);   // подробные ошибки, отладочная панель
defined('YII_ENV') or define('YII_ENV', 'dev');      // dev | prod | test
Константа Что даёт
YII_DEBUG = trueподробные страницы ошибок со стеком, больше логов. На проде — false
YII_ENV = 'dev'YII_ENV_DEV === true: в конфиге включаются модули debug и gii
YII_ENV = 'prod'значение по умолчанию, YII_ENV_PROD === true
YII_ENV = 'test'окружение для тестов, YII_ENV_TEST === true

Что уже есть в приложении#

  • Четыре страницы: главная, About, Contact (форма обратной связи, шлёт письмо) и Login.
  • Внизу каждой страницы в режиме dev — панель отладчика: запросы к БД, логи, профилирование.
  • Консольный скрипт ./yii для миграций, генерации кода и своих команд — см. Консольные команды.

Виртуальный хостинг#

На shared-хостинге обычно один webroot (www, htdocs, public_html) и нет доступа к конфигу сервера:

  1. Переименуйте web/ в имя webroot хостинга (например, public_html) ещё локально, а остальные папки загрузите на уровень выше.
  2. Для Apache положите .htaccess с правилами перезаписи рядом с index.php (см. выше). Для Nginx ничего не нужно.
  3. Проверьте требования, скопировав requirements.php в webroot, и удалите его после проверки.
  4. Для advanced сведите два webroot в один: содержимое frontend/web → www/, backend/web → www/admin/, поправив пути в index.php. Разведите куки и сессии бэкенда — свои csrfParam, имя identityCookie, имя сессии и path => '/admin' — иначе они пересекутся с фронтендом.

Docker#

Официальные образы — yii2-docker; шаблон basic поддерживает Docker из коробки.

Консоль
docker-compose up -d                          # поднять все сервисы в фоне
docker-compose ps                             # что запущено
docker-compose logs -f                        # хвост логов
docker-compose run --rm php composer install  # разовая команда в новом контейнере
docker-compose exec php bash                  # шелл в работающем контейнере
docker-compose stop                           # остановить
docker-compose down -v                        # остановить и удалить вместе с томами (данные!)

Свой шаблон проекта#

Шаблон — это обычный репозиторий с composer.json, зарегистрированный как пакет. Клонируйте yii2-app-basic, удалите .git, поправьте name, description, require, обновите README, опубликуйте на Packagist (или в своём репозитории Composer) — и создавайте проекты через composer create-project vendor/yii2-app-mytemplate. В extra.writable composer.json перечисляют папки, которым после установки нужны права на запись.

Проверь себя5

Снаружи должен быть доступен только входной скрипт и статика. Конфиги, код, runtime/ и vendor/ остаются недосягаемыми по HTTP.

Страницы ошибок будут показывать стек вызовов и фрагменты исходного кода всем посетителям — утечка информации о внутренностях приложения.

Три отдельных приложения (frontend, backend, console) с общим common, механизм окружений php init, готовые регистрация и восстановление пароля.

Чтобы Yii::$app->request->isSecureConnection возвращал true и генерировались правильные абсолютные URL при HTTPS.

php requirements.php в корне проекта (или открыть /requirements.php в браузере).

03Первое приложение

Четыре шага из руководства «Первое знакомство» в одном месте: страница «Привет», форма с валидацией, список из базы данных и генерация CRUD в Gii.

Здесь весь путь новичка: действие и представление → модель и форма → Active Record с пагинацией → CRUD за минуту в Gii. Каждый шаг показывает одну часть MVC и знакомит с идеями, которые дальше разбираются подробно.

Шаг 1. Страница «Привет»: действие + представление#

Действие — публичный метод контроллера с префиксом action. Параметры действия берутся из $_GET по имени.

controllers/SiteController.phpPHP
namespace app\controllers;

use yii\web\Controller;

class SiteController extends Controller
{
    public function actionSay($message = 'Привет')
    {
        return $this->render('say', ['message' => $message]);
    }
}
views/site/say.phpPHP
<?php use yii\helpers\Html; ?>
<?= Html::encode($message) ?>

Открываем index.php?r=site/say&message=Привет+мир. Параметр r — это маршрут вида контроллер/действие; site/say → SiteController::actionSay().

Правила именования, которые нужно запомнить:

Идентификатор Класс / метод
контроллер siteapp\controllers\SiteController
контроллер post-commentapp\controllers\PostCommentController
действие sayactionSay()
действие create-commentactionCreateComment()
представление say контроллера siteviews/site/say.php

render() автоматически оборачивает результат в шаблон views/layouts/main.php — поэтому у новой страницы сразу есть шапка и подвал сайта.

Шаг 2. Форма: модель + валидация#

Модель формы наследует yii\base\Model: публичные свойства — это атрибуты, метод rules() — правила проверки.

models/EntryForm.phpPHP
namespace app\models;

use yii\base\Model;

class EntryForm extends Model
{
    public $name;
    public $email;

    public function rules()
    {
        return [
            [['name', 'email'], 'required'],
            ['email', 'email'],
        ];
    }
}
controllers/SiteController.phpPHP
public function actionEntry()
{
    $model = new EntryForm();

    // load() кладёт $_POST['EntryForm'] в атрибуты, validate() проверяет по rules()
    if ($model->load(Yii::$app->request->post()) && $model->validate()) {
        return $this->render('entry-confirm', ['model' => $model]);
    }

    return $this->render('entry', ['model' => $model]);   // первый показ или ошибки
}
views/site/entry.phpPHP
<?php
use yii\helpers\Html;
use yii\widgets\ActiveForm;
?>
<?php $form = ActiveForm::begin(); ?>
    <?= $form->field($model, 'name')->label('Ваше имя') ?>
    <?= $form->field($model, 'email') ?>
    <div class="form-group">
        <?= Html::submitButton('Отправить', ['class' => 'btn btn-primary']) ?>
    </div>
<?php ActiveForm::end(); ?>

Yii::$app — глобальный объект приложения и одновременно Service Locator: через него доступны компоненты request, response, db, user и другие.

Шаг 3. Данные из базы: Active Record + пагинация#

Подключение описано в config/db.php и доступно как Yii::$app->db:

config/db.phpPHP
return [
    'class' => 'yii\db\Connection',
    'dsn' => 'mysql:host=localhost;dbname=yii2basic',
    'username' => 'root',
    'password' => '',
    'charset' => 'utf8',
];

Класс Active Record без единой строки кода уже умеет всё — имя таблицы выводится из имени класса (Country → country):

models/Country.phpPHP
namespace app\models;

use yii\db\ActiveRecord;

class Country extends ActiveRecord
{
}
PHP
$countries = Country::find()->orderBy('name')->all();   // все строки как объекты
$country = Country::findOne('US');                       // по первичному ключу
$country->name = 'U.S.A.';
$country->save();                                        // UPDATE

Контроллер со списком и постраничной разбивкой:

controllers/CountryController.phpPHP
namespace app\controllers;

use yii\web\Controller;
use yii\data\Pagination;
use app\models\Country;

class CountryController extends Controller
{
    public function actionIndex()
    {
        $query = Country::find();

        $pagination = new Pagination([
            'defaultPageSize' => 5,
            'totalCount' => $query->count(),
        ]);

        $countries = $query->orderBy('name')
            ->offset($pagination->offset)
            ->limit($pagination->limit)
            ->all();

        return $this->render('index', [
            'countries' => $countries,
            'pagination' => $pagination,
        ]);
    }
}
views/country/index.phpPHP
<?php
use yii\helpers\Html;
use yii\widgets\LinkPager;
?>
<h1>Countries</h1>
<ul>
<?php foreach ($countries as $country): ?>
    <li><?= Html::encode("{$country->code} ({$country->name})") ?>: <?= $country->population ?></li>
<?php endforeach; ?>
</ul>
<?= LinkPager::widget(['pagination' => $pagination]) ?>

Pagination считает LIMIT/OFFSET по параметру page из URL, а LinkPager рисует кнопки страниц. Всё это подробно — в разделах Active Record и Провайдеры данных.

Шаг 4. Gii: генерация модели и CRUD#

Gii — модуль, включённый в config/web.php только для YII_ENV_DEV:

PHP
if (YII_ENV_DEV) {
    $config['bootstrap'][] = 'gii';
    $config['modules']['gii'] = [
        'class' => 'yii\gii\Module',
        'allowedIPs' => ['127.0.0.1', '::1', '192.168.0.*'],   // по умолчанию только localhost
    ];
}

Открываем index.php?r=gii и:

  1. Model Generator: таблица country → класс Country. Кнопка Preview покажет файл, diff — отличия от существующего, флажок overwrite перезапишет.
  2. CRUD Generator: app\models\Country, поисковая модель app\models\CountrySearch, контроллер app\controllers\CountryController.
  3. Получаем controllers/CountryController.php, models/CountrySearch.php и views/country/*.php — таблицу с сортировкой и фильтрами, просмотр, создание, редактирование, удаление.
Проверь себя5

app\controllers\PostCommentController::actionCreateReply() — дефисы убираются, слова капитализируются, добавляются суффикс Controller и префикс action.

Копирует $_POST['EntryForm'] в безопасные атрибуты модели и возвращает true; если данных нет — false, и действие просто показывает форму.

И на клиенте (JavaScript, сгенерированный из rules()), и на сервере при вызове validate(). Доверять можно только серверной.

Только настроенное подключение db и таблица country в базе: имя таблицы, атрибуты и типы столбцов читаются из схемы автоматически.

Из соображений безопасности: генератор пишет файлы на диск. Разрешённые адреса задаются в allowedIPs.

🏗 Структура приложения

04Приложение и его компоненты

Из чего состоит приложение Yii, что делает входной скрипт, какие свойства приложения стоит настроить и как работают компоненты через Yii::$app.

Приложение Yii — это объект Yii::$app, созданный входным скриптом по массиву-конфигурации. Он держит компоненты (БД, запрос, ответ, пользователь…), знает, где лежат контроллеры и представления, и проводит каждый запрос через свой жизненный цикл.

Что есть в приложении кроме MVC#

Входной скрипт

единственный PHP-файл, доступный снаружи (web/index.php или консольный yii). Создаёт приложение и запускает обработку запроса.

Приложение

глобальный объект, координирующий всё остальное; доступен как Yii::$app.

Компоненты приложения

именованные сервисы (db, request, user, cache…), зарегистрированные в конфигурации и создаваемые лениво.

Модули

самодостаточные мини-приложения со своими контроллерами, моделями и представлениями. См. Модули.

Фильтры

код до и после действия контроллера: доступ, кеш, CORS. См. Фильтры.

Виджеты

переиспользуемые блоки интерфейса внутри представлений. См. Виджеты.

Входной скрипт#

Он делает пять вещей: объявляет константы, подключает автозагрузчик Composer и класс Yii, читает конфигурацию, создаёт приложение и вызывает run().

PHP
<?php
defined('YII_DEBUG') or define('YII_DEBUG', true);
defined('YII_ENV') or define('YII_ENV', 'dev');

require __DIR__ . '/../vendor/autoload.php';          // автозагрузчик Composer
require __DIR__ . '/../vendor/yiisoft/yii2/Yii.php';  // класс Yii + автозагрузчик Yii + DI-контейнер

$config = require __DIR__ . '/../config/web.php';

(new yii\web\Application($config))->run();
PHP
#!/usr/bin/env php
<?php
defined('YII_DEBUG') or define('YII_DEBUG', true);

require __DIR__ . '/vendor/autoload.php';
require __DIR__ . '/vendor/yiisoft/yii2/Yii.php';

$config = require __DIR__ . '/config/console.php';

$exitCode = (new yii\console\Application($config))->run();
exit($exitCode);

Константы объявляют в самом начале, до подключения других файлов:

YII_DEBUG
отладочный режим: подробные ошибки со стеком, больше логов. По умолчанию false
YII_ENV
окружение: prod (по умолчанию), dev, test; порождает YII_ENV_PROD, YII_ENV_DEV, YII_ENV_TEST
YII_ENABLE_ERROR_HANDLER
включать ли обработчик ошибок Yii. По умолчанию true

defined('X') or define('X', v) — идиома «определить, если ещё не определено»: так константу можно переопределить снаружи, например в тестовом входном скрипте.

Объект приложения#

Два вида: yii\web\Application для HTTP и yii\console\Application для команд. Оба наследуют yii\base\Application, которая в свою очередь — модуль и Service Locator.

Обязательные свойства#

config/web.phpPHP
return [
    'id' => 'basic',                    // уникальный идентификатор приложения
    'basePath' => dirname(__DIR__),     // корень с кодом; задаёт псевдоним @app
    // ...
];

Свойства, которые настраивают чаще всего#

aliases
массив псевдонимов путей: ['@uploads' => '@webroot/uploads']. Удобнее, чем Yii::setAlias() в коде
bootstrap
что создать при старте каждого запроса: ID компонентов/модулей, классы, конфиги, замыкания. Компонент с BootstrapInterface получит вызов bootstrap($app). Держите список коротким — он выполняется всегда
components
главное свойство: регистрация компонентов приложения (см. ниже)
controllerMap
явное соответствие ID контроллера → класс, когда соглашение об именах не подходит
controllerNamespace
пространство имён контроллеров, по умолчанию app\controllers; admin/post ищется в app\controllers\admin\PostController
modules
подключённые модули: 'forum' => ['class' => 'app\modules\forum\Module']
params
глобальные параметры: Yii::$app->params['adminEmail']. Обычно подключают config/params.php
language / sourceLanguage
язык интерфейса (ru-RU) и язык исходного кода (en-US по умолчанию); см. i18n
timeZone
часовой пояс приложения, по сути date_default_timezone_set()
catchAll
только web: маршрут, обрабатывающий все запросы (режим обслуживания): ['site/offline', 'param' => 'value']
name, version, charset
название для показа пользователям, версия, кодировка (UTF-8)

Свойства с разумными умолчаниями#

defaultRoute
маршрут без явного указания: site для web (→ site/index), help для консоли
layout, layoutPath, viewPath
шаблон по умолчанию main в @app/views/layouts, представления в @app/views. layout => false отключает шаблон
runtimePath
временные файлы (логи, кеш): @app/runtime, псевдоним @runtime. Должна быть доступна на запись и закрыта от веба
vendorPath
пакеты Composer: @app/vendor, псевдоним @vendor
extensions
список установленных расширений; Composer сам генерирует @vendor/yiisoft/extensions.php
enableCoreCommands
консоль: включать ли встроенные команды (migrate, cache, asset…). По умолчанию true

События приложения#

Обработчики вешают прямо в конфигурации через синтаксис 'on имяСобытия' или кодом после создания приложения:

PHP
return [
    'on beforeRequest' => function ($event) {
        // приложение создано, запрос ещё не обрабатывался — удобно выбрать язык по домену
    },
    'on beforeAction' => function (\yii\base\ActionEvent $event) {
        if ($somethingWrong) {
            $event->isValid = false;   // отменить выполнение действия
        }
    },
    'on afterAction' => function (\yii\base\ActionEvent $event) {
        $event->result = strtoupper($event->result);   // изменить результат действия
    },
];
Событие Когда Полезно для
beforeRequestдо начала обработки запросапереключение языка, темы, редиректы на обслуживание
afterRequestпосле обработки, до отправки ответапостобработка, статистика
beforeActionперед действием; сначала у приложения, затем у модуля, затем у контроллерапроверки доступа «на самом верху»
afterActionпосле действия; в обратном порядке: контроллер → модуль → приложениеобёртка результата

Жизненный цикл приложения#

  1. Входной скрипт читает конфигурацию — обычный PHP-массив из config/web.php.
  2. Создаётся объект приложения: preInit() применяет критичные свойства (basePath), регистрируется обработчик ошибок, применяется остальная конфигурация, init() вызывает bootstrap() — подключаются расширения и компоненты из bootstrap.
  3. run() обрабатывает запрос: событие beforeRequest → разбор маршрута компонентом request → создание модуля/контроллера/действия → выполнение → событие afterRequest → отправка ответа.
  4. Скрипт получает код выхода и завершает работу.

Подробнее о каждом шаге — в разделе Жизненный цикл запроса.

Компоненты приложения#

Приложение — это Service Locator: набор именованных объектов, к которым обращаются как Yii::$app->id. Компонент создаётся при первом обращении и дальше переиспользуется в рамках запроса.

config/web.phpPHP
'components' => [
    'cache' => 'yii\caching\ApcCache',                 // просто имя класса
    'db' => [                                          // массив конфигурации
        'class' => 'yii\db\Connection',
        'dsn' => 'mysql:host=localhost;dbname=demo',
        'username' => 'root',
        'password' => '',
        'charset' => 'utf8',
    ],
    'search' => function () {                           // фабрика-замыкание
        return new app\components\SolrService('127.0.0.1');
    },
],

Если компонент нужен в каждом запросе, даже когда к нему явно не обращаются (например, log должен ловить сообщения с самого начала), добавьте его ID в bootstrap:

PHP
'bootstrap' => ['log'],

Встроенные компоненты#

У них фиксированные ID и классы по умолчанию — достаточно указать только нужные свойства.

ID Класс За что отвечает
requestyii\web\Requestпараметры запроса, заголовки, куки, разбор маршрута
responseyii\web\Responseкод состояния, заголовки, тело, форматирование ответа
urlManageryii\web\UrlManagerразбор и генерация URL, правила ЧПУ
useryii\web\Userтекущий аутентифицированный пользователь (только web)
sessionyii\web\Sessionсессия и flash-сообщения (только web)
dbyii\db\Connectionсоединение с БД; класс и dsn нужно указать явно
errorHandleryii\web\ErrorHandlerперехват ошибок и исключений
logyii\log\Dispatcherмаршрутизация сообщений лога по целям
viewyii\web\Viewрендеринг представлений, регистрация скриптов
assetManageryii\web\AssetManagerпубликация и подключение JS/CSS
formatteryii\i18n\Formatterформатирование дат, чисел, размеров
i18nyii\i18n\I18Nпереводы сообщений
maileryii\symfonymailer\Mailer / yii\swiftmailer\Mailerотправка писем
cacheпо соглашению yii\caching\Cacheкеш данных; не встроен, но ожидается многими компонентами
Проверь себя5

id и basePath. Второе определяет псевдоним @app, от которого считаются runtimePath, viewPath и другие пути.

Лениво — при первом обращении Yii::$app->id. Чтобы создать его при старте каждого запроса, ID добавляют в bootstrap.

beforeAction: приложение → модуль → контроллер. afterAction: контроллер → модуль → приложение.

'catchAll' => ['site/offline'] — все запросы пойдут в это действие.

Чтобы константу можно было определить раньше (например, в тестовом входном скрипте или на сервере) без правки кода — повторный define вызвал бы ошибку.

05Контроллеры и действия

Как маршрут превращается в вызов метода, чем встроенные действия отличаются от отдельных, откуда берутся параметры и что может вернуть действие.

Контроллер принимает запрос, дёргает модели и отдаёт результат в представление. Он состоит из действий — публичных методов actionXxx() или отдельных классов. Маршрут post/view — это PostController::actionView().

Минимальный контроллер#

controllers/PostController.phpPHP
namespace app\controllers;

use Yii;
use app\models\Post;
use yii\web\Controller;
use yii\web\NotFoundHttpException;

class PostController extends Controller
{
    public function actionView($id)
    {
        $model = Post::findOne($id);
        if ($model === null) {
            throw new NotFoundHttpException();          // → страница 404
        }
        return $this->render('view', ['model' => $model]);
    }

    public function actionCreate()
    {
        $model = new Post();
        if ($model->load(Yii::$app->request->post()) && $model->save()) {
            return $this->redirect(['view', 'id' => $model->id]);
        }
        return $this->render('create', ['model' => $model]);
    }
}

Веб-контроллеры наследуют yii\web\Controller, консольные — yii\console\Controller.

Маршруты и имена#

Маршрут: контроллер/действие или модуль/контроллер/действие. Идентификаторы — в нижнем регистре, слова через дефис.

ID контроллера Класс
articleapp\controllers\ArticleController
post-commentapp\controllers\PostCommentController
admin/post-commentapp\controllers\admin\PostCommentController (подпапка без модуля)

Пространство имён задаёт controllerNamespace приложения (по умолчанию app\controllers). Если соглашение не подходит — например, контроллер из чужой библиотеки — используйте карту:

PHP
'controllerMap' => [
    'account' => 'app\controllers\UserController',
    'article' => [
        'class' => 'app\controllers\PostController',
        'enableCsrfValidation' => false,
    ],
],

Маршрут без действия → defaultAction контроллера (index); запрос без маршрута → defaultRoute приложения (site). Оба можно переопределить:

PHP
class SiteController extends Controller
{
    public $defaultAction = 'home';
}

Действия#

Встроенные (inline)#

Публичный метод с префиксом action. Имя метода — из ID действия: hello-world → actionHelloWorld.

Отдельные (standalone)#

Класс, унаследованный от yii\base\Action, с методом run(). Регистрируется в карте действий контроллера. Так удобно переиспользовать действия между контроллерами и в расширениях:

PHP
public function actions()
{
    return [
        'error' => 'yii\web\ErrorAction',                      // страница ошибки
        'captcha' => ['class' => 'yii\captcha\CaptchaAction'],
        'page' => ['class' => 'yii\web\ViewAction', 'viewPrefix' => 'pages'],   // статические страницы
    ];
}
components/HelloWorldAction.phpPHP
namespace app\components;

use yii\base\Action;

class HelloWorldAction extends Action
{
    public $greeting = 'Hello';

    public function run($name = 'World')
    {
        return "{$this->greeting}, {$name}";
    }
}

ID отдельных действий могут быть любыми — они заданы явно в actions().

Результат действия#

Что вернули Что произойдёт
строкастанет телом ответа (обычно результат $this->render())
объект Responseотправится как есть — так работает $this->redirect()
массив / любые данныепопадёт в Response::data и отформатируется по Response::format (JSON, XML…)
число в консоликод выхода команды
PHP
public function actionForward()
{
    return $this->redirect('https://example.com');   // redirect() возвращает Response
}

public function actionInfo()
{
    Yii::$app->response->format = \yii\web\Response::FORMAT_JSON;
    return ['message' => 'hello', 'code' => 100];
}

Параметры действий#

Параметры метода заполняются из $_GET по имени (в консоли — из аргументов командной строки):

PHP
public function actionView($id, $version = null) { /* ... */ }
  • ?r=post/view&id=123 → $id = '123', $version = null;
  • ?r=post/view → BadRequestHttpException — обязательный параметр не передан;
  • ?r=post/view&id[]=123 → BadRequestHttpException — ожидался скаляр, пришёл массив.

Чтобы принять массив, укажите тип: public function actionView(array $id) — тогда скаляр автоматически обернётся в массив.

Жизненный цикл контроллера#

  1. init() — контроллер создан и сконфигурирован.
  2. Создание действия по ID: сначала карта actions(), затем метод actionXxx(), иначе InvalidRouteException.
  3. beforeAction() последовательно у приложения, модуля и контроллера. Вернул false — остальные пропускаются, действие не выполняется. Здесь работают фильтры.
  4. Выполнение действия с параметрами из запроса.
  5. afterAction() в обратном порядке: контроллер → модуль → приложение.
  6. Результат уходит в компонент response.

Что ещё умеет контроллер#

render($view, $params)
представление + шаблон (layout)
renderPartial($view, $params)
без шаблона
renderAjax($view, $params)
без шаблона, но с зарегистрированными JS/CSS — для ответов на AJAX
renderContent($html)
обернуть готовую строку в шаблон
redirect($url, $statusCode = 302)
перенаправление; $url может быть маршрутом-массивом
goHome(), goBack(), refresh()
на главную, на предыдущий URL (см. Url::remember()), перезагрузка текущей страницы
asJson($data), asXml($data)
сами выставляют response->format и возвращают объект ответа: его нужно вернуть из действия
behaviors()
фильтры контроллера: доступ, HTTP-методы, кеш
$layout
свой шаблон для этого контроллера; false — без шаблона
$enableCsrfValidation
проверка CSRF-токена для POST (по умолчанию включена)

Типичный CRUD#

Так выглядит контроллер, который генерирует Gii, — хороший образец «тонкого» контроллера:

PHP
use yii\filters\VerbFilter;
use yii\web\NotFoundHttpException;

class PostController extends Controller
{
    public function behaviors()
    {
        return [
            'verbs' => [
                'class' => VerbFilter::class,
                'actions' => ['delete' => ['POST']],
            ],
        ];
    }

    public function actionIndex()
    {
        $searchModel = new PostSearch();
        $dataProvider = $searchModel->search(Yii::$app->request->queryParams);
        return $this->render('index', compact('searchModel', 'dataProvider'));
    }

    public function actionUpdate($id)
    {
        $model = $this->findModel($id);
        if ($model->load(Yii::$app->request->post()) && $model->save()) {
            Yii::$app->session->setFlash('success', 'Сохранено');
            return $this->redirect(['view', 'id' => $model->id]);
        }
        return $this->render('update', ['model' => $model]);
    }

    public function actionDelete($id)
    {
        $this->findModel($id)->delete();
        return $this->redirect(['index']);
    }

    protected function findModel($id)
    {
        if (($model = Post::findOne($id)) !== null) {
            return $model;
        }
        throw new NotFoundHttpException('Запись не найдена.');
    }
}
Проверь себя5

app\controllers\admin\PostCommentController::actionCreateReply() — префикс до слеша трактуется как подпапка пространства имён.

Это самостоятельный класс: его можно переиспользовать в разных контроллерах, настраивать через конфигурацию и распространять в расширениях (ErrorAction, CaptchaAction, ViewAction).

Будет выброшено BadRequestHttpException (HTTP 400) ещё до выполнения действия.

Установить Yii::$app->response->format = Response::FORMAT_JSON и вернуть массив, либо вызвать return $this->asJson($data).

Приложение → модуль → контроллер; любой false останавливает цепочку.

06Модели

Атрибуты и метки, сценарии, правила валидации, безопасное массовое присваивание и экспорт модели в массив.

Модель — это данные + правила их проверки + бизнес-логика. Базовый класс yii\base\Model даёт атрибуты, метки, сценарии, валидацию, массовое присваивание и toArray(). Active Record наследует всё это и добавляет работу с таблицей.

Атрибуты#

Атрибуты — это публичные нестатические свойства класса. К ним можно обращаться и как к свойствам, и как к элементам массива:

PHP
class ContactForm extends \yii\base\Model
{
    public $name;
    public $email;
    public $subject;
    public $body;
}

$model = new ContactForm();
$model->name = 'Иван';
$model['email'] = 'ivan@example.com';        // ArrayAccess
foreach ($model as $attr => $value) { }      // Traversable

Список атрибутов возвращает attributes(); Active Record переопределяет его и берёт имена столбцов таблицы.

Метки и подсказки#

По умолчанию метка генерируется из имени: firstName → «First Name». Явно — в attributeLabels(); подсказки для форм — в attributeHints():

PHP
public function attributeLabels()
{
    return [
        'name' => Yii::t('app', 'Ваше имя'),
        'email' => Yii::t('app', 'E-mail'),
    ];
}

public function attributeHints()
{
    return ['email' => 'Мы не будем присылать спам'];
}

echo $model->getAttributeLabel('name');   // Ваше имя

Сценарии#

Одна модель — разные ситуации: при регистрации email обязателен, при входе — нет. Сценарий хранится в свойстве scenario (по умолчанию default):

PHP
$model = new User(['scenario' => User::SCENARIO_LOGIN]);
// или
$model->scenario = User::SCENARIO_LOGIN;

Сценарий определяет активные атрибуты — те, что проходят валидацию и могут быть присвоены массово. По умолчанию сценарии выводятся из правил rules() (ключи on/except), но их можно объявить явно:

PHP
class User extends ActiveRecord
{
    const SCENARIO_LOGIN = 'login';
    const SCENARIO_REGISTER = 'register';

    public function scenarios()
    {
        $scenarios = parent::scenarios();                       // сохраняем сценарии из rules()
        $scenarios[self::SCENARIO_LOGIN] = ['username', 'password'];
        $scenarios[self::SCENARIO_REGISTER] = ['username', 'email', 'password'];
        return $scenarios;
    }

    public function rules()
    {
        return [
            [['username', 'email', 'password'], 'required', 'on' => self::SCENARIO_REGISTER],
            [['username', 'password'], 'required', 'on' => self::SCENARIO_LOGIN],
            ['email', 'email'],                                 // во всех сценариях
        ];
    }
}

Правила валидации#

PHP
public function rules()
{
    return [
        [['name', 'email', 'subject', 'body'], 'required'],
        ['email', 'email'],
        ['subject', 'string', 'max' => 100, 'on' => 'contact'],
        // именованное правило — легко удалить в наследнике
        'password' => [['password'], 'string', 'min' => 8],
    ];
}

if ($model->validate()) {
    // всё хорошо
} else {
    $errors = $model->errors;               // ['email' => ['Email is not a valid email address.']]
    $model->getFirstError('email');
    $model->hasErrors();
}

Формат правила: [атрибуты, валидатор, 'on' => сценарии, 'except' => сценарии, свойство => значение…]. Валидатор — псевдоним встроенного (required, email, string…), имя метода модели или класс. Полный разбор — в разделах Валидация и Встроенные валидаторы.

Массовое присваивание#

Одной строкой вместо десятка $model->x = $data['x']:

PHP
$model->attributes = Yii::$app->request->post('ContactForm');   // напрямую
$model->load(Yii::$app->request->post());                       // то же, но сам возьмёт $_POST[formName()]

load() возвращает true, если данные для модели вообще были в массиве — это удобно для «показать форму или обработать».

Безопасные атрибуты#

Массово присваиваются только безопасные атрибуты — активные в текущем сценарии. Остальные молча игнорируются. Так пользователь не сможет через форму выставить себе role = admin.

PHP
public function rules()
{
    return [
        [['title', 'description'], 'safe'],   // безопасны без проверки
    ];
}

public function scenarios()
{
    return [
        // ! — проверять, но не присваивать массово
        self::SCENARIO_LOGIN => ['username', 'password', '!secret'],
    ];
}
$model->secret = $value;   // только явно

Экспорт в массив#

PHP
$array = $model->attributes;                          // все атрибуты
$array = $model->toArray();                           // поля из fields()
$array = $model->toArray([], ['prettyName', 'fullAddress']);   // + поля из extraFields()

fields() определяет, что попадёт в массив по умолчанию, extraFields() — что можно запросить дополнительно (так работает ?expand= в REST):

PHP
public function fields()
{
    return [
        'id',
        'email' => 'email_address',                    // переименование
        'name' => function () {                        // вычисляемое поле
            return $this->first_name . ' ' . $this->last_name;
        },
    ];
}

// или: взять родительские поля и убрать чувствительные
public function fields()
{
    $fields = parent::fields();
    unset($fields['auth_key'], $fields['password_hash'], $fields['password_reset_token']);
    return $fields;
}

Лучшие практики#

  • Модели — толстые, контроллеры — тонкие: бизнес-логика живёт здесь.
  • Модель не должна лезть в $_GET, сессию или запрос — эти данные ей передаёт контроллер.
  • Никакого HTML в моделях.
  • Не плодите сценарии в одной модели; для сложных форм заводите отдельные классы (LoginForm, SignupForm).
  • В больших проектах: базовая модель в common\models\Post с общими правилами, наследники frontend\models\Post и backend\models\Post — со своей спецификой.
Проверь себя5

load() сам находит данные по имени формы ($data['ContactForm']) и возвращает false, если их нет; присваивание attributes работает с уже «распакованным» массивом. Оба присваивают только безопасные атрибуты.

Он не является безопасным: безопасные атрибуты — это активные атрибуты текущего сценария, а по умолчанию сценарии строятся из правил. Добавьте правило safe.

Атрибут валидируется, но не присваивается массово — только явно через $model->secret = ....

Переопределить fields() и убрать поле из массива родительской реализации.

В rules() через 'on' => 'login' / 'on' => 'register' или явно перечислив активные атрибуты в scenarios().

07Представления и шаблоны

PHP-шаблоны, правила поиска файлов, передача данных, layouts и блоки, компонент view, темизация и подключение Twig/Smarty.

Представление — обычный PHP-файл с HTML, в котором $this — это компонент yii\web\View. Контроллер вызывает render(), результат оборачивается в шаблон (layout) и уходит в ответ. Главное правило: всё пользовательское — через Html::encode().

Как выглядит представление#

views/site/login.phpPHP
<?php
use yii\helpers\Html;
use yii\widgets\ActiveForm;

/** @var \yii\web\View $this */
/** @var \app\models\LoginForm $model */

$this->title = 'Вход';
?>
<h1><?= Html::encode($this->title) ?></h1>

<?php $form = ActiveForm::begin(); ?>
    <?= $form->field($model, 'username') ?>
    <?= $form->field($model, 'password')->passwordInput() ?>
    <?= Html::submitButton('Войти') ?>
<?php ActiveForm::end(); ?>

Переменные ($model, $form) приходят из контроллера. Докблок с @var в начале файла — не обязателен, но даёт автодополнение в IDE.

Безопасность вывода#

PHP
<?= Html::encode($user->name) ?>              <!-- текст: экранируем < > & " ' -->
<?= HtmlPurifier::process($post->text) ?>     <!-- HTML от пользователя: вырезаем опасное -->

HtmlPurifier надёжен, но медленный — результат стоит кешировать.

Где лежат файлы#

Кто рендерит Папка по умолчанию
контроллер PostController@app/views/post/
контроллер PostCommentController@app/views/post-comment/
контроллер модуляviews/post/ внутри папки модуля
виджетviews/ рядом с классом виджета

Изменить папку можно, переопределив getViewPath() контроллера или виджета.

Как рендерить#

$this->render('view', $params)
в контроллере: представление + шаблон
$this->renderPartial('_item', $params)
в контроллере: без шаблона (фрагменты, AJAX)
$this->renderAjax('form', $params)
без шаблона, но с зарегистрированными JS/CSS — для подгрузки модалок
$this->renderFile('@app/views/site/license.php')
по пути или псевдониму
$this->render('_overview')
внутри представления: $this уже View, ищет рядом с текущим файлом
Yii::$app->view->renderFile(...)
откуда угодно (письма, консоль)

Как разрешается имя представления#

Имя Где ищется
aboutв папке контекста: @app/views/site/about.php для SiteController
_item из views/post/index.phpрядом: views/post/_item.php
/site/aboutв viewPath текущего модуля (или приложения)
//site/aboutвсегда в @app/views/site/about.php
@app/views/site/aboutпсевдоним — как есть

Расширение .php можно не писать.

Данные в представлении#

PHP
// явно — через второй параметр: массив превращается в переменные через extract()
return $this->render('report', ['foo' => 1, 'bar' => 2]);

// из контекста
ID контроллера: <?= $this->context->id ?>

// общие данные между представлением и шаблоном
$this->params['breadcrumbs'][] = 'О нас';     // в представлении
Breadcrumbs::widget(['links' => $this->params['breadcrumbs'] ?? []]);   // в шаблоне

Явная передача предпочтительнее: представление не зависит от того, кто его вызвал.

Шаблоны (layouts)#

Шаблон — тоже представление; в нём $content — результат рендеринга страницы. Лежат в @app/views/layouts, по умолчанию используется main.

views/layouts/main.phpPHP
<?php
use yii\helpers\Html;
use app\assets\AppAsset;

AppAsset::register($this);
?>
<?php $this->beginPage() ?>
<!DOCTYPE html>
<html lang="<?= Yii::$app->language ?>">
<head>
    <meta charset="<?= Yii::$app->charset ?>">
    <?= Html::csrfMetaTags() ?>
    <title><?= Html::encode($this->title) ?></title>
    <?php $this->head() ?>
</head>
<body>
<?php $this->beginBody() ?>
    <header>Моя компания</header>
    <?= $content ?>
    <footer>&copy; <?= date('Y') ?></footer>
<?php $this->endBody() ?>
</body>
</html>
<?php $this->endPage() ?>

Пять служебных вызовов обязательны: beginPage()/endPage() оборачивают страницу, head() — место для мета-тегов и CSS, beginBody()/endBody() — для скриптов в начале и конце <body>. Без них зарегистрированные ресурсы не появятся.

Какой шаблон применится#

  1. layout контроллера, если не null; иначе — первый не-null layout по цепочке модулей вверх до приложения.
  2. Значение трактуется как: псевдоним (@app/views/layouts/main), абсолютный путь от папки шаблонов приложения (/main), относительный — от папки шаблонов модуля-контекста (main), либо false — без шаблона.
PHP
class PostController extends Controller
{
    public $layout = 'post';       // @app/views/layouts/post.php только для этого контроллера
}

Вложенные шаблоны и блоки#

views/layouts/column2.phpPHP
<?php $this->beginContent('@app/views/layouts/main.php'); ?>
<div class="row">
    <div class="col-8"><?= $content ?></div>
    <div class="col-4"><?= $this->blocks['sidebar'] ?? 'Боковая панель по умолчанию' ?></div>
</div>
<?php $this->endContent(); ?>
views/post/view.phpPHP
<?php $this->beginBlock('sidebar'); ?>
    <h3>Похожие статьи</h3>
<?php $this->endBlock(); ?>

Блок «записывается» в представлении и выводится в шаблоне — так страница управляет частями общей разметки.

Компонент view#

PHP
$this->title = 'Заголовок';                                             // → <title> в шаблоне
$this->registerMetaTag(['name' => 'description', 'content' => '…'], 'description');   // ключ = без дублей
$this->registerLinkTag(['rel' => 'alternate', 'type' => 'application/rss+xml', 'href' => '/rss']);
$this->registerCss('body { background: #fff }');
$this->registerJs("$('#btn').on('click', …);", View::POS_READY, 'btn-handler');
$this->registerCssFile('@web/css/print.css', ['media' => 'print']);
$this->registerJsFile('@web/js/main.js', ['depends' => [JqueryAsset::class]]);
AppAsset::register($this);                                              // лучший способ: пакеты ресурсов

Подробно о скриптах и стилях — в разделе Ресурсы и клиентские скрипты.

События View: beforeRender/afterRender (можно отменить или изменить вывод через $event->output), beginPage, endPage, beginBody, endBody:

PHP
Yii::$app->view->on(View::EVENT_END_BODY, function () {
    echo '<!-- rendered ' . date('c') . ' -->';
});

Статические страницы#

Вместо десятка одинаковых actionAbout() — одно отдельное действие:

PHP
public function actions()
{
    // ?r=site/page&view=about → views/site/pages/about.php
    return ['page' => ['class' => 'yii\web\ViewAction']];
}

Темизация#

Тема подменяет файлы представлений без правки кода. Настраивается у компонента view:

PHP
'view' => [
    'theme' => [
        'basePath' => '@app/themes/basic',
        'baseUrl' => '@web/themes/basic',
        'pathMap' => [
            // views/site/about.php → themes/basic/site/about.php
            '@app/views' => '@app/themes/basic',
            '@app/modules' => '@app/themes/basic/modules',         // темизация модулей
            '@app/widgets' => '@app/themes/basic/widgets',         // темизация виджетов
        ],
    ],
],

Замена идёт по префиксу пути. Несколько значений в pathMap дают наследование тем — берётся первый существующий файл:

PHP
'@app/views' => ['@app/themes/christmas', '@app/themes/basic'],

В представлении тема доступна как $this->theme: $theme->getUrl('img/logo.png'), $theme->getPath(...).

Twig и Smarty#

Шаблонизаторы подключаются расширениями yiisoft/yii2-twig и yiisoft/yii2-smarty через рендереры по расширению файла:

PHP
'view' => [
    'renderers' => [
        'twig' => [
            'class' => 'yii\twig\ViewRenderer',
            'cachePath' => '@runtime/Twig/cache',
            'options' => ['auto_reload' => true],
            'globals' => ['html' => '\yii\helpers\Html'],
        ],
        'tpl' => ['class' => 'yii\smarty\ViewRenderer'],
    ],
],

После этого $this->render('index') найдёт index.twig или index.tpl; файлы .php продолжают работать как раньше.

Лучшие практики#

  • В представлении — только разметка и простой PHP для вывода; никаких запросов к БД и доступа к $_GET.
  • Читать свойства моделей можно, менять — нет.
  • Крупные представления дробите на части (_item.php, _form.php) и виджеты; форматирование выносите в хелперы.
Проверь себя5

Оба не применяют шаблон, но renderAjax() дополнительно выводит зарегистрированные JS/CSS — нужен для фрагментов, которые подгружаются по AJAX и содержат виджеты.

Двойной слеш всегда означает папку представлений приложения: @app/views/site/error.php, минуя модуль.

Это точки, куда фреймворк подставляет зарегистрированные мета-теги, CSS и JS. Без них ресурсы, зарегистрированные представлениями и виджетами, на страницу не попадут.

public $layout = 'post'; в классе контроллера; для действия — $this->layout = false; перед render() или вернуть renderPartial().

Через $this->title и $this->params['breadcrumbs'] — оба свойства компонента View, общего для представления и шаблона.

08Модули

Как упаковать часть функциональности в самостоятельный модуль: структура, класс Module, маршруты, доступ к модулю, вложенность и URL-правила.

Модуль — это «приложение в приложении»: свои контроллеры, модели, представления, компоненты и параметры. Модули не запускаются отдельно, но их удобно переиспользовать между проектами: форум, блог, админка, версия API.

Структура#

modules/forum/
    Module.php                  класс модуля (обязателен, в корне модуля)
    controllers/
        DefaultController.php   контроллер по умолчанию
        PostController.php
    models/
    views/
        layouts/                шаблоны модуля
        default/index.php       представления контроллера default
        post/
    migrations/
    config.php                  необязательно: конфигурация модуля

Класс модуля#

modules/forum/Module.phpPHP
namespace app\modules\forum;

class Module extends \yii\base\Module
{
    public $controllerNamespace = 'app\modules\forum\controllers';   // так и по умолчанию

    public function init()
    {
        parent::init();
        $this->params['foo'] = 'bar';
        \Yii::configure($this, require __DIR__ . '/config.php');   // components, params… из файла

        if (\Yii::$app instanceof \yii\console\Application) {
            $this->controllerNamespace = 'app\modules\forum\commands';   // консольные команды модуля
        }
    }
}

Модуль — тоже Service Locator: в нём можно регистрировать компоненты ('components' => [...]) и параметры, доступные как $module->params.

Контроллеры и представления модуля#

modules/forum/controllers/PostController.phpPHP
namespace app\modules\forum\controllers;

class PostController extends \yii\web\Controller
{
    public function actionIndex()
    {
        return $this->render('index');   // modules/forum/views/post/index.php
    }
}

Шаблон модуля задаётся свойством layout модуля и ищется в views/layouts модуля; если не задан — используется шаблон приложения. Соглашения об именах те же, что у контроллеров приложения; отклонения — через controllerMap модуля.

Подключение и маршруты#

config/web.phpPHP
'modules' => [
    'forum' => [
        'class' => 'app\modules\forum\Module',
        'params' => ['postsPerPage' => 20],
    ],
],

Маршрут внутри модуля начинается с его ID: forum/post/index → PostController::actionIndex() модуля forum. Маршрут forum целиком → defaultRoute модуля (default) → DefaultController::actionIndex().

Доступ к модулю из кода#

PHP
$module = \Yii::$app->getModule('forum');          // по ID
$module = \Yii::$app->controller->module;          // модуль текущего контроллера
$module = \app\modules\forum\Module::getInstance();  // экземпляр, созданный для текущего запроса (или null)

$max = $module->params['postsPerPage'];

Предзагрузка и URL-правила#

Модуль создаётся лениво — при первом обращении по маршруту. Если он должен работать в каждом запросе (например, добавлять свои правила ЧПУ), укажите его в bootstrap и реализуйте BootstrapInterface:

PHP
'bootstrap' => ['forum'],
PHP
class Module extends \yii\base\Module implements \yii\base\BootstrapInterface
{
    public function bootstrap($app)
    {
        $app->urlManager->addRules([
            ['class' => 'yii\web\GroupUrlRule', 'prefix' => 'forum', 'rules' => [
                'posts' => 'post/index',
                'post/<id:\d+>' => 'post/view',
            ]],
        ], false);
    }
}

Вложенные модули#

PHP
class Module extends \yii\base\Module
{
    public function init()
    {
        parent::init();
        $this->modules = [
            'admin' => ['class' => 'app\modules\forum\modules\admin\Module'],
        ];
    }
}

Маршрут: forum/admin/dashboard/index. getModule('admin') работает только для прямых потомков; все загруженные модули — в Yii::$app->loadedModules.

Когда нужны модули#

  • Крупное приложение, которое делится на области ответственности (каждую ведёт своя команда).
  • Функциональность, которую вы будете переносить между проектами: пользователи, комментарии, платежи.
  • Версии REST API (v1, v2) — см. REST: версионирование.

Если группировать контроллеры хочется, а всё остальное общее, — достаточно подпапки в controllers/ (маршрут admin/post), модуль не нужен.

Проверь себя4

defaultRoute модуля (по умолчанию default) и defaultAction контроллера — DefaultController::actionIndex().

Модуль создаётся при разборе маршрута, когда UrlManager::parseRequest() уже отработал. Правила добавляют в bootstrap() модуля, включённого в bootstrap приложения.

MyModule::getInstance() — вернёт экземпляр, обслуживающий текущий запрос, или Yii::$app->controller->module.

Приложение — потомок yii\base\Module: у обоих есть components, params, modules, controllerNamespace, layout, события beforeAction/afterAction.

09Фильтры

Код до и после действия: как подключать фильтры, в каком порядке они срабатывают, как написать свой и что умеют встроенные — AccessControl, VerbFilter, HttpCache, PageCache, Cors и другие.

Фильтр — это поведение, которое вклинивается в beforeAction() и afterAction() контроллера. Пре-фильтр может остановить выполнение (доступ запрещён, метод не тот), пост-фильтр — обработать результат (кеш, сжатие).

Подключение#

Фильтры объявляют в behaviors() контроллера, модуля или приложения:

PHP
use yii\filters\HttpCache;

public function behaviors()
{
    return [
        [
            'class' => HttpCache::class,
            'only' => ['index', 'view'],             // только эти действия
            'except' => [],                         // или все, кроме перечисленных
            'lastModified' => function ($action, $params) {
                return (new \yii\db\Query())->from('user')->max('updated_at');
            },
        ],
    ];
}

Порядок выполнения#

  1. Пре-фильтры приложения в порядке объявления в behaviors().
  2. Пре-фильтры модуля, затем контроллера — тоже по порядку. Любой вернул false — стоп, остальные фильтры и действие пропускаются.
  3. Действие.
  4. Пост-фильтры в обратном порядке: контроллер → модуль → приложение.

Свой фильтр#

Наследуем yii\base\ActionFilter, переопределяем beforeAction() и/или afterAction():

components/ActionTimeFilter.phpPHP
namespace app\components;

use Yii;
use yii\base\ActionFilter;

class ActionTimeFilter extends ActionFilter
{
    private $_startTime;

    public function beforeAction($action)
    {
        $this->_startTime = microtime(true);
        return parent::beforeAction($action);      // true — выполнять действие
    }

    public function afterAction($action, $result)
    {
        Yii::debug("{$action->uniqueId}: " . (microtime(true) - $this->_startTime) . ' c');
        return parent::afterAction($action, $result);   // можно подменить $result
    }
}

Встроенные фильтры#

AccessControl — кто может#

PHP
use yii\filters\AccessControl;

'access' => [
    'class' => AccessControl::class,
    'only' => ['create', 'update', 'delete'],
    'rules' => [
        ['allow' => true, 'actions' => ['create'], 'roles' => ['@']],          // @ — аутентифицирован
        ['allow' => true, 'actions' => ['update', 'delete'], 'roles' => ['admin']],   // роль RBAC
        ['allow' => false, 'ips' => ['10.0.0.*']],
        // всё, что не совпало ни с одним правилом, — запрещено
    ],
    'denyCallback' => function ($rule, $action) {
        throw new \yii\web\ForbiddenHttpException('Нельзя');
    },
],

Правила проверяются сверху вниз до первого совпадения. ? — гость, @ — вошедший; другие роли уходят в Yii::$app->user->can(). Детали и RBAC — в разделе Авторизация.

VerbFilter — какими HTTP-методами#

PHP
use yii\filters\VerbFilter;

'verbs' => [
    'class' => VerbFilter::class,
    'actions' => [
        'index'  => ['GET'],
        'create' => ['GET', 'POST'],
        'delete' => ['POST', 'DELETE'],   // иначе 405 Method Not Allowed
    ],
],

Аутентификация: HttpBasicAuth, HttpBearerAuth, QueryParamAuth, CompositeAuth#

Живут в yii\filters\auth и нужны в основном для REST API. Класс identity должен реализовать findIdentityByAccessToken():

PHP
'authenticator' => ['class' => \yii\filters\auth\HttpBearerAuth::class],

ContentNegotiator — формат ответа и язык#

Смотрит на Accept, GET-параметры и выставляет Response::format и Yii::$app->language. Можно повесить и как компонент bootstrap, чтобы формат определился в самом начале запроса:

PHP
use yii\filters\ContentNegotiator;
use yii\web\Response;

[
    'class' => ContentNegotiator::class,
    'formats' => [
        'application/json' => Response::FORMAT_JSON,
        'application/xml' => Response::FORMAT_XML,
    ],
    'languages' => ['ru', 'en-US'],   // первый — по умолчанию
],

HttpCache и PageCache — кеширование#

PHP
use yii\filters\PageCache;
use yii\caching\DbDependency;

'pageCache' => [
    'class' => PageCache::class,
    'only' => ['index'],
    'duration' => 60,
    'dependency' => ['class' => DbDependency::class, 'sql' => 'SELECT COUNT(*) FROM post'],
    'variations' => [\Yii::$app->language],
],

HttpCache шлёт Last-Modified/ETag, чтобы браузер не качал страницу заново; PageCache хранит готовый HTML на сервере. Подробно — в Кеширование вывода.

RateLimiter — не чаще N запросов#

Реализует «дырявое ведро»; identity должен реализовать RateLimitInterface. См. REST: лимиты.

Cors — запросы с других доменов#

Ставьте перед фильтрами аутентификации, иначе заголовки CORS не дойдут до preflight-запроса:

PHP
use yii\filters\Cors;
use yii\helpers\ArrayHelper;

public function behaviors()
{
    return ArrayHelper::merge([
        [
            'class' => Cors::class,
            'cors' => [
                'Origin' => ['https://app.example.com'],
                'Access-Control-Request-Method' => ['GET', 'POST', 'OPTIONS'],
                'Access-Control-Request-Headers' => ['*'],
                'Access-Control-Allow-Credentials' => true,
                'Access-Control-Max-Age' => 86400,
            ],
            'actions' => [
                'login' => ['Access-Control-Allow-Credentials' => true],   // переопределение для действия
            ],
        ],
    ], parent::behaviors());
}

HostControl — защита от подмены заголовка Host#

Уровень приложения, если нельзя настроить веб-сервер:

PHP
'as hostControl' => [
    'class' => 'yii\filters\HostControl',
    'allowedHosts' => ['example.com', '*.example.com'],
    'fallbackHostInfo' => 'https://example.com',
],

Сводка по фильтрам#

Фильтр Задача Типичное место
AccessControlдоступ по ролям, IP, методам, callbackконтроллеры с CRUD
VerbFilterограничить HTTP-методыdelete только POST
auth\*аутентификация по токенуREST-контроллеры
ContentNegotiatorJSON/XML и язык по AcceptREST, bootstrap
HttpCacheкеш в браузерепубличные страницы
PageCacheкеш страницы на серверетяжёлые страницы
RateLimiterлимит запросовREST
Corsкросс-доменные запросыAPI для SPA
HostControlбелый список хостовконфигурация приложения
Проверь себя5

Остальные пре-фильтры и само действие не выполнятся; пост-фильтры тоже пропускаются.

Браузер сначала шлёт preflight-запрос OPTIONS без токена; если аутентификация сработает первой, она вернёт 401 и CORS-заголовки не уйдут.

В обратном относительно пре-фильтров: сначала контроллер, потом модуль, потом приложение.

VerbFilter с 'delete' => ['POST', 'DELETE'] — на GET вернётся 405.

HttpCache управляет кешем на стороне клиента заголовками Last-Modified/ETag; PageCache хранит готовый HTML на сервере в компоненте кеша.

10Виджеты

Переиспользуемые блоки интерфейса: как использовать widget() и begin()/end(), как написать свой виджет с представлением и ресурсами, как задать умолчания через DI.

Виджет — это класс, который рисует кусок интерфейса: форму, меню, таблицу, пагинатор, date picker. Он инкапсулирует разметку, логику и нужные JS/CSS, поэтому в представлении достаточно одной строки.

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

Два способа вызова:

PHP
<?php
use yii\widgets\ActiveForm;
use yii\widgets\LinkPager;
use yii\helpers\Html;
?>

<!-- 1. widget(): конфигурация → готовый HTML -->
<?= LinkPager::widget(['pagination' => $pagination, 'maxButtonCount' => 5]) ?>

<!-- 2. begin()/end(): виджет с содержимым между вызовами -->
<?php $form = ActiveForm::begin(['id' => 'login-form']); ?>
    <?= $form->field($model, 'username') ?>
    <?= $form->field($model, 'password')->passwordInput() ?>
    <?= Html::submitButton('Войти', ['class' => 'btn btn-primary']) ?>
<?php ActiveForm::end(); ?>

widget() возвращает строку, begin() — экземпляр виджета (поэтому у $form можно вызывать field()), а end() выводит результат.

Виджеты из коробки#

Виджет Для чего
ActiveForm, ActiveFieldформы на основе модели с клиентской валидацией — см. Формы
GridView, ListView, DetailViewтаблицы и списки из провайдера данных — см. Виджеты данных
LinkPager, LinkSorterссылки постраничной навигации и сортировки
Menu, Breadcrumbsменю с подсветкой активного пункта, хлебные крошки
Pjaxобновление части страницы без перезагрузки
FragmentCacheкеширование фрагмента — обёртка beginCache()/endCache()
MaskedInput, Captcha, InputWidgetполя ввода
yii\bootstrap*\*Alert, Modal, Nav, NavBar, Tabs, Dropdown и другие компоненты Bootstrap (расширение)
yii\jui\*DatePicker, Autocomplete, Slider и другие jQuery UI (расширение)

Умолчания для всех виджетов#

Через DI-контейнер можно задать свойства по умолчанию для любого класса — например, чтобы все пагинаторы показывали 5 кнопок:

PHP
\Yii::$container->set('yii\widgets\LinkPager', ['maxButtonCount' => 5]);

// или в конфигурации приложения (с 2.0.11)
'container' => [
    'definitions' => [
        'yii\widgets\LinkPager' => ['maxButtonCount' => 5],
    ],
],

Свой виджет#

Наследуем yii\base\Widget: в init() нормализуем свойства, в run() возвращаем HTML.

components/HelloWidget.phpPHP
namespace app\components;

use yii\base\Widget;
use yii\helpers\Html;

class HelloWidget extends Widget
{
    public $message;

    public function init()
    {
        parent::init();
        if ($this->message === null) {
            $this->message = 'Hello World';
        }
    }

    public function run()
    {
        return Html::encode($this->message);
    }
}
PHP
<?= HelloWidget::widget(['message' => 'Доброе утро']) ?>

Виджет с содержимым#

Чтобы принять то, что написано между begin() и end(), включаем буферизацию вывода в init() и забираем её в run():

PHP
class HelloWidget extends Widget
{
    public function init()
    {
        parent::init();
        ob_start();
    }

    public function run()
    {
        $content = ob_get_clean();
        return Html::tag('div', Html::encode($content), ['class' => 'hello']);
    }
}
PHP
<?php HelloWidget::begin(); ?>
    содержимое, которое может содержать <tag>'и
<?php HelloWidget::end(); ?>

Виджет с представлением#

Большую разметку удобнее держать в файле. Представления виджета лежат в views/ рядом с классом (@app/components/views/hello.php); папку меняет getViewPath().

PHP
public function run()
{
    return $this->render('hello', ['message' => $this->message]);
}

Виджет с ресурсами#

Если виджету нужны CSS/JS, регистрируйте пакет ресурсов в $this->view — тогда виджет остаётся самодостаточным:

PHP
public function run()
{
    DatePickerAsset::register($this->view);
    $this->view->registerJs("jQuery('#{$this->id}').datepicker();");
    return Html::activeTextInput($this->model, $this->attribute, ['id' => $this->id]);
}

Для полей ввода, привязанных к модели, наследуйте yii\widgets\InputWidget — он уже умеет model/attribute или name/value.

Лучшие практики#

  • Виджет следует MVC: логика в классе, разметка в представлении.
  • Виджет должен работать «из коробки» после вставки в представление — все зависимости через asset bundles, никаких ручных <script>.
  • Если виджет — только разметка без логики, возможно, достаточно частичного представления (_item.php). Виджет выигрывает, когда его нужно распространять как класс: между модулями, проектами, в расширении.
Проверь себя4

widget() сразу возвращает готовый HTML; begin() возвращает объект виджета и ждёт end(), между ними можно выводить содержимое (как в ActiveForm).

В папке views/ рядом с файлом класса виджета; изменить можно переопределив getViewPath().

Зарегистрировать конфигурацию в DI-контейнере: Yii::$container->set(LinkPager::class, ['maxButtonCount' => 5]).

В $this->view — объект представления, в котором он рендерится; лучше всего через пакет ресурсов SomeAsset::register($this->view).

11Ресурсы и клиентские скрипты

Asset bundles — единственно правильный способ подключать JS и CSS: зависимости, публикация, Bower/NPM через Composer, объединение и сжатие, а также registerJs/registerCss и yii.js.

Ресурсы (assets) — JS, CSS, картинки, шрифты. Yii управляет ими пакетами (AssetBundle): пакет знает, где лежат файлы, от каких других пакетов зависит, и сам публикует их в веб-доступную папку. Зарегистрировал пакет в представлении — теги <link> и <script> появились в нужном порядке.

Пакет ресурсов#

assets/AppAsset.phpPHP
namespace app\assets;

use yii\web\AssetBundle;

class AppAsset extends AssetBundle
{
    public $basePath = '@webroot';          // файлы уже доступны из веба
    public $baseUrl = '@web';
    public $css = ['css/site.css'];
    public $js = ['js/app.js'];
    public $depends = [
        'yii\web\YiiAsset',                 // jQuery + yii.js
        'yii\bootstrap5\BootstrapAsset',
    ];
}
views/layouts/main.phpPHP
<?php
use app\assets\AppAsset;
AppAsset::register($this);   // $this — объект View; в виджете: $this->view
?>
sourcePath
папка с исходниками вне веб-корня (расширения, @bower/...). Файлы будут опубликованы в @webroot/assets
basePath + baseUrl
файлы уже в веб-корне, публиковать не нужно. Так делают для ресурсов самого приложения
js, css
списки файлов: относительные пути или абсолютные URL (CDN). Порядок сохраняется
depends
пакеты, которые должны быть подключены раньше. Зависимости транзитивны
jsOptions, cssOptions
параметры для registerJsFile()/registerCssFile(): ['position' => View::POS_HEAD], ['media' => 'print'], ['condition' => 'lte IE9'], ['noscript' => true]
publishOptions
что публиковать: ['only' => ['css/*', 'fonts/*']], forceCopy, beforeCopy

Три вида ресурсов#

  • Исходные — лежат рядом с PHP-кодом, недоступны из веба; при регистрации публикуются (копируются или линкуются) в @webroot/assets/<hash>/.
  • Опубликованные — уже в веб-папке (basePath/baseUrl).
  • Внешние — на CDN, задаются полным URL.

Bower и NPM через Composer#

Основной способ — репозиторий asset-packagist (в шаблонах basic/advanced с 2.0.13 уже настроен):

composer.jsonJSON
"repositories": [
    {"type": "composer", "url": "https://asset-packagist.org"}
],
"require": {
    "bower-asset/jquery": "^3.6",
    "npm-asset/chart.js": "^4.0"
}
PHP
'aliases' => [
    '@bower' => '@vendor/bower-asset',
    '@npm' => '@vendor/npm-asset',
],

Дальше пакет ссылается на них через sourcePath = '@npm/chart.js/dist'. Альтернатива — глобальный плагин fxp/composer-asset-plugin, но он заметно медленнее.

Настройка через assetManager#

Все пакеты можно перенастроить централизованно, не трогая их классы:

PHP
'assetManager' => [
    'bundles' => [
        'yii\web\JqueryAsset' => [
            'sourcePath' => null,                         // не публиковать
            'js' => ['//code.jquery.com/jquery-3.7.1.min.js'],   // взять с CDN
        ],
        'yii\bootstrap5\BootstrapPluginAsset' => false,   // отключить пакет совсем
    ],
    'assetMap' => [
        'jquery.js' => '//code.jquery.com/jquery-3.7.1.min.js',   // заменить файл во всех пакетах
    ],
    'linkAssets' => true,        // публиковать симлинками, а не копированием
    'appendTimestamp' => true,   // /assets/abc/yii.js?v=1423448645 — сброс кеша браузера
],

'bundles' => false отключает все пакеты — полезно, когда фронтенд собирается отдельно.

Препроцессоры#

В css/js можно перечислять .less, .scss, .styl, .coffee, .ts — AssetConverter вызовет установленный компилятор (lessc, sass, tsc…) и подключит результат. Команды настраиваются в assetManager.converter.commands. Чаще, впрочем, сборку доверяют внешним инструментам и в пакет включают уже готовые файлы.

Объединение и сжатие#

На проде десятки файлов хочется склеить в один CSS и один JS. Идея: создать «сводный» пакет и заставить остальные зависеть от него с пустыми js/css. Команда yii asset делает это автоматически:

Консоль
yii asset/template assets.php          # шаблон конфигурации
yii asset assets.php config/assets-prod.php   # объединить, сжать, сгенерировать конфиг пакетов
assets.php (фрагмент)PHP
return [
    'jsCompressor' => 'java -jar compiler.jar --js {from} --js_output_file {to}',
    'cssCompressor' => 'java -jar yuicompressor.jar --type css {from} -o {to}',
    'bundles' => ['app\assets\AppAsset', 'yii\web\YiiAsset'],
    'targets' => [
        'all' => [
            'class' => 'yii\web\AssetBundle',
            'basePath' => '@webroot/assets',
            'baseUrl' => '@web/assets',
            'js' => 'js/all-{hash}.js',
            'css' => 'css/all-{hash}.css',
        ],
    ],
    'assetManager' => ['basePath' => '@webroot/assets', 'baseUrl' => '@web/assets'],
];

Несколько targets (например, allShared, allBackEnd, allFrontEnd) группируют пакеты; цель с пустым depends собирает всё оставшееся. Результат подключают по окружению:

PHP
'assetManager' => [
    'bundles' => require __DIR__ . '/' . (YII_ENV_PROD ? 'assets-prod.php' : 'assets-dev.php'),
],

Регистрация скриптов и стилей напрямую#

Для мелких динамических кусков — методы View:

PHP
$this->registerJs(
    "$('#myButton').on('click', function () { alert('Clicked'); });",
    View::POS_READY,          // POS_HEAD | POS_BEGIN | POS_END | POS_READY (по умолчанию) | POS_LOAD
    'my-button-handler'       // ключ: повторная регистрация заменит, а не продублирует
);
$this->registerJsFile('@web/js/main.js', ['depends' => [\yii\web\JqueryAsset::class]]);
$this->registerCss('body { background: #f00; }');
$this->registerCssFile('@web/css/print.css', ['media' => 'print'], 'css-print');

POS_READY и POS_LOAD оборачивают код в jQuery(function(){}) и автоматически подключают JqueryAsset.

PHP-значения в JavaScript#

Экранируйте через Json::htmlEncode() — иначе кавычки и </script> в данных сломают страницу:

PHP
$options = ['baseUrl' => Yii::$app->request->baseUrl, 'language' => Yii::$app->language];
$this->registerJs('var appOptions = ' . \yii\helpers\Json::htmlEncode($options) . ';', View::POS_HEAD, 'app-options');
$this->registerJsVar('appOptions', $options, View::POS_HEAD);   // то же одной строкой (с 2.0.14)

$message = \yii\helpers\Json::htmlEncode(Yii::t('app', 'Button clicked!'));
$this->registerJs(<<<JS
    $('#myButton').on('click', function () { alert($message); });
JS);

Что даёт yii.js#

YiiAsset подключает yii.js — небольшую библиотеку, на которой держатся встроенные виджеты:

  • отправка CSRF-токена в AJAX-запросах (берётся из Html::csrfMetaTags());
  • атрибуты data-method="post" и data-confirm="Точно удалить?" на ссылках — так работает кнопка удаления в GridView;
  • обработка заголовка X-Redirect для перенаправлений в AJAX-ответах;
  • система модулей yii.* для клиентского кода виджетов (yii.activeForm, yii.gridView).
Проверь себя5

sourcePath — если файлы лежат вне веб-корня и их надо опубликовать (расширения, npm-пакеты). basePath/baseUrl — если файлы уже в web/ и публикация не нужна.

Чтобы гарантировать порядок подключения: файлы пакета выводятся после файлов всех его зависимостей (например, плагин после jQuery).

'appendTimestamp' => true у assetManager — к URL добавится ?v=<время изменения файла>.

Ключ блока: повторная регистрация с тем же ключом заменяет предыдущую, а не добавляет дубликат. Без ключа ключом служит сам код.

Такие изменения имеют приоритет над assetManager->bundles и ломают централизованную перенастройку и объединение ресурсов командой yii asset.

12Расширения и сторонний код

Как ставить расширения через Composer, как оформить своё (composer.json, bootstrap-класс, ресурсы, миграции), список официальных расширений и как подружить Yii с чужими библиотеками и системами.

Расширение — Composer-пакет типа yii2-extension: виджеты, модули, хелперы, компоненты. Устанавливается одной командой, автозагружается сам, при необходимости регистрирует свой bootstrap-код. Обычные библиотеки (не для Yii) подключаются так же через Composer.

Установка#

Консоль
composer require yiisoft/yii2-imagine "~2.0.0"
PHP
use yii\imagine\Image;

Image::thumbnail('@webroot/img/test-image.jpg', 120, 120)
    ->save(Yii::getAlias('@runtime/thumb.jpg'), ['quality' => 50]);

Composer обновляет vendor/yiisoft/extensions.php — из него приложение узнаёт о расширениях, их псевдонимах и bootstrap-классах. Классы загружаются автозагрузчиком без настройки.

Вручную, без Composer#

Распаковать в vendor/, подключить автозагрузчик библиотеки (если есть) или объявить псевдоним для PSR-4-совместимого кода:

PHP
'aliases' => ['@myext' => '@vendor/mycompany/myext'],   // классы myext\Foo → vendor/mycompany/myext/Foo.php

Официальные расширения#

Инструменты

yii2-debug (панель отладки), yii2-gii (генератор кода), yii2-apidoc (документация API), yii2-faker (тестовые данные).

UI

yii2-bootstrap / yii2-bootstrap4 / yii2-bootstrap5 (виджеты Bootstrap), yii2-jui (jQuery UI).

Данные

yii2-redis, yii2-mongodb, yii2-elasticsearch, yii2-sphinx: Active Record, кеш, сессии для NoSQL-хранилищ.

Сервисы

yii2-httpclient (HTTP-клиент), yii2-authclient (OAuth: Google, GitHub, Facebook…), yii2-symfonymailer / yii2-swiftmailer (почта), yii2-imagine (картинки).

Шаблонизаторы

yii2-twig, yii2-smarty.

Все — yiisoft/yii2-* на Packagist; каталог сообщества: yiiframework.com/extensions.

Своё расширение#

  1. Репозиторий + composer.json. Имя vendor/yii2-name, тип yii2-extension, зависимость от yiisoft/yii2, автозагрузка PSR-4.
  2. Пространство имён vendor\name (без префикса yii2-). Слова yii, yii2, yiisoft в качестве vendor зарезервированы.
  3. Регистрация на Packagist (или в своём репозитории Composer для приватных пакетов), теги версий по semver.
composer.jsonJSON
{
    "name": "myname/yii2-mywidget",
    "type": "yii2-extension",
    "description": "Виджет для…",
    "license": "BSD-3-Clause",
    "require": {
        "yiisoft/yii2": "~2.0.0"
    },
    "autoload": {
        "psr-4": {"myname\\mywidget\\": "src/"}
    },
    "extra": {
        "bootstrap": "myname\\mywidget\\Bootstrap"
    }
}

Для каждого корневого пространства имён из autoload Yii создаст псевдоним (@myname/mywidget).

Bootstrap-класс#

Код, который должен выполняться при старте каждого запроса (регистрация URL-правил, обработчиков событий, зависимостей DI), выносят в класс с BootstrapInterface и указывают в extra.bootstrap:

PHP
namespace myname\mywidget;

use yii\base\Application;
use yii\base\BootstrapInterface;

class Bootstrap implements BootstrapInterface
{
    public function bootstrap($app)
    {
        $app->on(Application::EVENT_BEFORE_REQUEST, function () {
            // ...
        });
        \Yii::$container->set('myname\mywidget\StorageInterface', 'myname\mywidget\FileStorage');
    }
}

Рекомендации авторам#

  • База данных: не полагайтесь на Yii::$app->db — объявите свойство db (как у yii\caching\DbCache), чтобы пользователь мог подставить своё соединение. Схему меняйте миграциями, без Active Record внутри них.
  • Ресурсы: объявляйте пакет ресурсов с sourcePath — публикация сделает файлы доступными из веба сама.
  • i18n: пользовательские строки оборачивайте в Yii::t(), числа и даты форматируйте через Formatter.
  • Тесты, changelog, readme, upgrade.md, документированный код в стиле ядра.

Сторонние библиотеки в Yii#

Пакеты Composer работают сразу — автозагрузчик vendor/autoload.php подключён во входном скрипте. Для библиотек без Composer:

  • есть свой автозагрузчик — подключить его во входном скрипте до Yii.php;
  • классы по PSR-4 — объявить псевдоним корневого пространства имён ('@xyz' => '@vendor/foo/bar');
  • совсем без автозагрузки — заполнить карту классов: Yii::$classMap['Class1'] = 'path/to/Class1.php'.

Yii внутри другой системы#

Active Record, хелперы, кеш и прочее можно использовать в WordPress, Joomla или приложении на другом фреймворке:

Консоль
composer require yiisoft/yii2
входной скрипт сторонней системыPHP
require __DIR__ . '/../vendor/yiisoft/yii2/Yii.php';

$yiiConfig = require __DIR__ . '/../config/yii/web.php';
new yii\web\Application($yiiConfig);   // НЕ вызывать run(): обработка запроса остаётся за хост-системой

После этого доступен Yii::$app со всеми компонентами из конфигурации — например, db с настройками базы хост-системы. Если ресурсы (Bower/NPM) не нужны, пригодится пакет cebe/assetfree-yii2.

Yii 2 рядом с Yii 1#

Оба фреймворка используют класс Yii, поэтому нужен объединённый класс: наследник yii\BaseYii, куда скопирован код YiiBase из 1.x. Затем во входном скрипте создаётся приложение Yii 2 (без run()), а Yii 1 запускается как обычно. В коде: Yii::$app — приложение Yii 2, Yii::app() — Yii 1. Полный рецепт — в главе руководства «Работа со сторонним кодом».

Проверь себя4

При установке Composer добавит его в vendor/yiisoft/extensions.php, и Yii узнает о псевдонимах и bootstrap-классе расширения автоматически.

Через класс, реализующий BootstrapInterface, указанный в extra.bootstrap файла composer.json.

Пользователь может держать данные в другом соединении; правильно — объявить настраиваемое свойство db компонента.

Создать new yii\web\Application($config), но не вызывать run() — обработкой запроса занимается хост-система.

🔁 Обработка запроса

13Жизненный цикл запроса

Одиннадцать шагов от index.php до ответа, что происходит на этапе предзагрузки и почему bootstrap должен быть лёгким.

Каждый запрос проходит один и тот же путь: входной скрипт → приложение → маршрут → контроллер → фильтры → действие → модель → представление → ответ. Понимая эту цепочку, легко найти, куда встроить свой код: события, фильтры, bootstrap.

Жизненный цикл запроса в Yii 2: браузер → входной скрипт → приложение → маршрут → контроллер → фильтры → действие → модель → представление → ответ → браузер Браузер HTTP-запрос index.php входной скрипт Application конфиг + bootstrap Маршрут urlManager → post/view Контроллер PostController Фильтры beforeAction() Действие actionView($id) Модель · БД Post::findOne($id) Представление render() + layout Ответ Response::send() 1. пользователь открывает URL фильтр вернул false → стоп afterAction() → EVENT_AFTER_REQUEST → браузеру

Узлы кликабельны — каждый ведёт к своему разделу.

Одиннадцать шагов#

  1. Пользователь открывает URL — запрос приходит во входной скрипт web/index.php.
  2. Скрипт загружает конфигурацию и создаёт объект yii\web\Application.
  3. Приложение разбирает маршрут с помощью компонента request (и urlManager при ЧПУ): post/view + параметры.
  4. Создаётся контроллер — PostController, по соглашению об именах или из controllerMap.
  5. Создаётся действие и к нему применяются фильтры (beforeAction приложения → модуля → контроллера).
  6. Любой фильтр не пропустил — обработка останавливается: редирект на вход, 403, 405…
  7. Все фильтры пройдены — выполняется действие.
  8. Действие работает с моделью — обычно это запросы к базе через Active Record.
  9. Действие рендерит представление, передавая ему данные модели; результат оборачивается в шаблон.
  10. Результат попадает в компонент response (afterAction в обратном порядке, событие afterRequest).
  11. response отправляет заголовки и тело браузеру.

Подробнее о шагах: приложение и входной скрипт, маршрутизация, контроллеры, фильтры, запрос и ответ.

Предзагрузка (bootstrapping)#

Всё, что происходит до обработки запроса, — предзагрузка. Она идёт в двух местах:

Во входном скрипте: регистрируются автозагрузчики (Composer, затем Yii через Yii.php), читается конфигурация, создаётся приложение.

В конструкторе приложения:

  1. preInit() — применяются самые важные свойства (basePath);
  2. регистрируется обработчик ошибок;
  3. применяется остальная конфигурация;
  4. init() → bootstrap():

    • подключается манифест vendor/yiisoft/extensions.php, создаются и запускаются bootstrap-классы расширений;
    • создаются компоненты и модули из свойства bootstrap приложения; у тех, кто реализует BootstrapInterface, вызывается bootstrap($app).

Что ещё ускоряет старт:

  • OPcache/APCu в проде — кеш байткода избавляет от разбора PHP-файлов при каждом запросе.
  • Кеширование конфигурации: если она собрана из десятка файлов, соберите один раз и читайте готовый массив из кеша до создания приложения.
  • Подробнее — в разделе Производительность.

Куда встроиться#

Момент Инструмент
до любого запроса (регистрация правил, зависимостей DI)BootstrapInterface + свойство bootstrap
до/после обработки запросасобытия beforeRequest/afterRequest приложения
до/после действияфильтры или события beforeAction/afterAction
перед отправкой ответасобытия beforeSend/afterPrepare/afterSend компонента response
при рендерингесобытия View: beforeRender, endBody…
Проверь себя4

На этапе фильтров: если один из beforeAction вернул false (нет доступа, неверный HTTP-метод), действие не выполняется.

Подключает расширения из extensions.php, вызывает их bootstrap-классы, создаёт компоненты и модули из свойства bootstrap и вызывает у них bootstrap($app), если реализован интерфейс.

Чтобы ошибки в самой конфигурации и при создании компонентов уже перехватывались и показывались понятно.

До подключения Yii.php — тогда автозагрузчик Yii встанет первым в очереди и будет проверять классы раньше остальных.

14Маршрутизация и URL

Как URL превращается в маршрут и обратно: обычный формат и ЧПУ, правила с параметрами, суффиксы, HTTP-методы, свои классы правил и Url::to().

Роутинг — разбор URL в маршрут (post/view) и параметры; генерация URL — обратный процесс. Обоими занимается компонент urlManager. Включив ЧПУ и описав правила, вы меняете вид адресов, не трогая код контроллеров: Url::to(['post/view', 'id' => 100]) сам подстроится.

Два формата URL#

Формат Пример Настройка
обычный/index.php?r=post/view&id=100не нужна, работает везде
ЧПУ (pretty URL)/post/100enablePrettyUrl => true + правила + перезапись на сервере
config/web.phpPHP
'urlManager' => [
    'enablePrettyUrl' => true,
    'showScriptName' => false,       // без /index.php в адресах — нужна настройка веб-сервера
    'enableStrictParsing' => false,  // true: URL без подходящего правила → 404
    'rules' => [
        'posts' => 'post/index',
        'post/<id:\d+>' => 'post/view',
    ],
],

Без showScriptName => false сервер сам находит index.php; с ним нужны правила перезаписи из раздела Установка.

Как разбирается маршрут#

После получения маршрута (из r или по правилам) он делится по слешам, и приложение идёт по частям:

  1. Текущий модуль — приложение. Часть ищется в его controllerMap → найден контроллер, переход к шагу 4.
  2. Иначе — среди модулей (modules) → модуль становится текущим, повторяем с шага 1 для следующей части.
  3. Иначе часть — ID контроллера, создаётся по соглашению об именах.
  4. Следующая часть — ID действия: сначала карта actions(), затем метод actionXxx().

Любой промах — NotFoundHttpException. Пустой маршрут → defaultRoute приложения (site/index), маршрут из одного ID модуля → defaultRoute модуля (default) → defaultAction контроллера (index).

Режим обслуживания — все запросы в одно действие:

PHP
'catchAll' => ['site/offline', 'reason' => 'update'],

Генерация URL#

PHP
use yii\helpers\Url;

Url::to(['post/index']);                                  // /posts
Url::to(['post/view', 'id' => 100]);                      // /post/100
Url::to(['post/view', 'id' => 100, '#' => 'comments']);   // /post/100#comments
Url::to(['post/view', 'id' => 100, 'src' => 'ad']);       // /post/100?src=ad — лишние параметры уходят в query
Url::to(['post/index'], true);                            // https://example.com/posts — абсолютный
Url::to(['post/index'], 'https');                         // с явной схемой

Url::to('@web/images/logo.png');   // строка: псевдоним или URL как есть
Url::to();                         // текущий URL
Url::home();  Url::base();  Url::canonical();  Url::current(['page' => 2]);
Url::remember();  Url::previous();               // запомнить и вернуться — так работает goBack()

Маршрут в массиве контекстно-зависим — как относительный путь:

Маршрут Значение (текущий модуль admin, контроллер post)
''текущий маршрут admin/post/index
indexдействие текущего контроллера: admin/post/index
post/indexотносительно текущего модуля: admin/post/index
/post/indexабсолютный: post/index
@postsпсевдоним, объявленный как маршрут

Правила URL#

Правило — yii\web\UrlRule (или свой класс): шаблон + маршрут + опции. Правила проверяются по порядку до первого совпадения — и при разборе, и при генерации.

PHP
'rules' => [
    // краткая форма: шаблон => маршрут
    'posts' => 'post/index',
    'posts/<year:\d{4}>/<category>' => 'post/index',
    'post/<id:\d+>' => 'post/view',

    // параметры в маршруте: одно правило вместо шести
    '<controller:(post|comment)>/<id:\d+>/<action:(create|update|delete)>' => '<controller>/<action>',
    '<controller:(post|comment)>/<id:\d+>' => '<controller>/view',
    '<controller:(post|comment)>s' => '<controller>/index',

    // полная форма — когда нужны опции
    [
        'pattern' => 'posts/<page:\d+>/<tag>',
        'route' => 'post/index',
        // необязательные параметры: /posts, /posts/2, /posts/2/news, /posts/news
        'defaults' => ['page' => 1, 'tag' => ''],
        'suffix' => '.json',
    ],

    // ограничение по HTTP-методу учитывается только при разборе;
    // при генерации ссылки метод не смотрят, поэтому правило может сработать и там
    'PUT,POST post/<id:\d+>' => 'post/update',
    'DELETE post/<id:\d+>' => 'post/delete',

    // имена серверов: поддомен → параметр
    'http://<language:\w+>.example.com/posts' => 'post/index',
    'https://admin.example.com/login' => 'admin/user/login',
],

Именованный параметр <name:regexp> без регулярки принимает всё, кроме /. Разобранные параметры попадают в $_GET и в аргументы действия.

Суффиксы и нормализация#

PHP
'urlManager' => [
    // все URL — с .html; без суффикса → 404 (хорошо для SEO); '/' — завершающий слеш
    'suffix' => '.html',
    'normalizer' => [
        'class' => 'yii\web\UrlNormalizer',   // /path/ и /path//x → 301 на канонический вид
        'action' => \yii\web\UrlNormalizer::ACTION_REDIRECT_TEMPORARY,
    ],
],

Суффикс и нормализатор можно переопределить у отдельного правила ('suffix' => '.json', 'normalizer' => false).

Правила из модулей#

Добавлять правила нужно на этапе предзагрузки, иначе они не успеют к разбору запроса:

PHP
public function bootstrap($app)
{
    $app->urlManager->addRules([
        ['class' => 'yii\web\GroupUrlRule', 'prefix' => 'forum', 'rules' => [
            'posts' => 'post/index',
            'post/<id:\d+>' => 'post/view',
        ]],
    ], false);
}

GroupUrlRule объединяет правила с общим префиксом — быстрее, чем перебирать их по одному.

Свой класс правила#

Когда шаблон зависит от данных (например, /Марка/Модель из БД), реализуют UrlRuleInterface:

PHP
namespace app\components;

use yii\base\BaseObject;
use yii\web\UrlRuleInterface;

class CarUrlRule extends BaseObject implements UrlRuleInterface
{
    public function createUrl($manager, $route, $params)
    {
        if ($route === 'car/index' && isset($params['manufacturer'])) {
            return $params['manufacturer'] . (isset($params['model']) ? '/' . $params['model'] : '');
        }
        return false;   // правило не применимо
    }

    public function parseRequest($manager, $request)
    {
        if (preg_match('%^(\w+)(/(\w+))?$%', $request->pathInfo, $m)) {
            // проверить $m[1], $m[3] по базе и вернуть ['car/index', $params]
        }
        return false;
    }
}
PHP
'rules' => [['class' => 'app\components\CarUrlRule']],

Производительность#

  • Правила с параметрами в маршруте (<controller>/<action>) заменяют десятки конкретных.
  • Частые и узкие правила — выше в списке.
  • Правила с общим префиксом — в GroupUrlRule.
  • Для REST — готовый yii\rest\UrlRule (см. REST).
Проверь себя5

При строгом разборе URL без подходящего правила даёт 404; иначе путь трактуется как маршрут напрямую (/posts/php → маршрут posts/php).

URL соберётся «в лоб»: /post/index?category=php — маршрут как путь, параметры как query string.

Нет: verb смотрят только при разборе запроса. Само правило из генерации не исключается — Url::to(['post/delete', 'id' => 1]) вполне может собрать адрес по правилу 'DELETE post/<id:\d+>'. Чтобы правило работало только на разбор, задайте ему 'mode' => UrlRule::PARSING_ONLY.

Указать его значение по умолчанию в defaults полной формы правила.

В методе bootstrap() (модуль включён в bootstrap приложения), не в init().

15Запрос и ответ

Компоненты request и response — параметры, заголовки, URL, доверенные прокси, коды состояния, форматы ответа, редиректы и отправка файлов.

Yii::$app->request — всё о входящем запросе: параметры, метод, заголовки, куки, IP. Yii::$app->response — всё об ответе: код, заголовки, тело, формат. Работайте с ними, а не с $_GET/header(): так код тестируется и не зависит от окружения.

Запрос#

Параметры#

PHP
$request = Yii::$app->request;

$request->get();                 // весь $_GET
$request->get('id');             // $_GET['id'] или null
$request->get('id', 1);          // со значением по умолчанию
$request->post('name', '');      // то же для $_POST

$request->bodyParams;            // тело PUT/PATCH/JSON-запроса
$request->getBodyParam('id');
$request->queryParams;           // = get()

Чтобы тело JSON-запросов разбиралось автоматически (нужно REST API):

PHP
'request' => [
    'parsers' => ['application/json' => 'yii\web\JsonParser'],
],

Метод, URL, заголовки, клиент#

PHP
$request->method;                                   // 'GET', 'POST'…
$request->isGet; $request->isPost; $request->isPut; $request->isAjax; $request->isPjax;

// для https://example.com/admin/index.php/product?id=100
$request->url;             // /admin/index.php/product?id=100
$request->absoluteUrl;     // https://example.com/admin/index.php/product?id=100
$request->hostInfo;        // https://example.com
$request->pathInfo;        // product
$request->queryString;     // id=100
$request->baseUrl;         // /admin
$request->scriptUrl;       // /admin/index.php
$request->serverName;      // example.com
$request->isSecureConnection;

$headers = $request->headers;                       // HeaderCollection
$headers->get('Accept'); $headers->has('User-Agent');
$request->userAgent; $request->contentType;
$request->acceptableContentTypes;                   // по убыванию q
$request->acceptableLanguages;
$request->getPreferredLanguage(['ru', 'en']);       // лучший из поддерживаемых

$request->userIP; $request->userHost;
$request->cookies;                                  // см. раздел о куках
$request->csrfToken;                                // для ручных AJAX-запросов

За обратным прокси#

Заголовкам X-Forwarded-* нельзя верить по умолчанию — их подставит любой клиент. Перечислите доверенные прокси:

PHP
'request' => [
    'trustedHosts' => ['10.0.2.0/24'],   // только их X-Forwarded-For / X-Forwarded-Proto учитываются
    // если прокси шлёт нестандартные заголовки:
    // 'trustedHosts' => ['10.0.2.0/24' => ['X-ProxyUser-Ip', 'Front-End-Https']],
    // 'ipHeaders' => ['X-ProxyUser-Ip'],
    // 'secureProtocolHeaders' => ['Front-End-Https' => ['on']],
],

Без этой настройки за прокси userIP будет адресом прокси, а isSecureConnection — false.

Ответ#

Код состояния#

PHP
Yii::$app->response->statusCode = 201;    // по умолчанию 200
throw new \yii\web\NotFoundHttpException();   // обработчик ошибок сам выставит 404
throw new \yii\web\HttpException(402, 'Payment required');
Исключение Код
BadRequestHttpException400
UnauthorizedHttpException401
ForbiddenHttpException403
NotFoundHttpException404
MethodNotAllowedHttpException405
NotAcceptableHttpException406
ConflictHttpException409
GoneHttpException410
UnsupportedMediaTypeHttpException415
TooManyRequestsHttpException429
ServerErrorHttpException500

Заголовки#

PHP
$headers = Yii::$app->response->headers;
$headers->add('Pragma', 'no-cache');     // добавить (существующие сохраняются)
$headers->set('Pragma', 'no-cache');     // заменить
$headers->remove('Pragma');

Имена регистронезависимы; отправятся при send().

Тело и формат#

PHP
// готовая строка
Yii::$app->response->content = 'hello';

// данные + формат: отформатирует JsonResponseFormatter
$response = Yii::$app->response;
// FORMAT_HTML | FORMAT_XML | FORMAT_JSON | FORMAT_JSONP | FORMAT_RAW
$response->format = \yii\web\Response::FORMAT_JSON;
$response->data = ['message' => 'hello'];

// обычно проще вернуть данные из действия
public function actionInfo()
{
    return $this->asJson(['message' => 'hello', 'code' => 100]);
}

Свои форматтеры добавляются в response->formatters. Настройка JSON (например, читаемый вывод в отладке):

PHP
'response' => [
    'formatters' => [
        \yii\web\Response::FORMAT_JSON => [
            'class' => 'yii\web\JsonResponseFormatter',
            'prettyPrint' => YII_DEBUG,
            'encodeOptions' => JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
        ],
    ],
],

Можно вернуть из действия и собственный объект Response, созданный через Yii::createObject(), — но он не получит настроек компонента response из конфигурации.

Перенаправление#

PHP
public function actionOld()
{
    return $this->redirect(['post/view', 'id' => 1]);          // 302
    return $this->redirect('https://example.com/new', 301);    // навсегда
    return $this->goHome();   // $this->goBack();   $this->refresh();
}

// вне действия — сразу отправить
Yii::$app->response->redirect($url)->send();

Для AJAX-запросов вместо Location уходит заголовок X-Redirect; yii.js из YiiAsset обрабатывает его автоматически.

Отправка файлов#

PHP
return Yii::$app->response->sendFile('/path/to/report.pdf');                    // существующий файл
return Yii::$app->response->sendContentAsFile($csv, 'export.csv');              // строка как файл
return Yii::$app->response->sendStreamAsFile($stream, 'big.zip');               // поток — для больших файлов
// отдачу делает веб-сервер (X-Sendfile / X-Accel-Redirect)
return Yii::$app->response->xSendFile('/path/to/file');

Все поддерживают заголовок Range (докачку).

Как отправляется ответ#

send() вызывается автоматически в конце run(), но его можно вызвать и раньше; повторный вызов игнорируется:

  1. событие beforeSend;
  2. prepare() — data форматируется в content;
  3. событие afterPrepare;
  4. sendHeaders();
  5. sendContent();
  6. событие afterSend.

События удобны, чтобы, например, обернуть все JSON-ответы в единый конверт — см. Ошибки и логирование.

Проверь себя5

Настроить парсер 'application/json' => 'yii\web\JsonParser' и читать Yii::$app->request->bodyParams / getBodyParam('id').

IP самого прокси — заголовки X-Forwarded-For игнорируются, пока прокси не объявлен доверенным.

return $this->asJson($data) или установить response->format = FORMAT_JSON и вернуть массив.

Не загружает файл в память целиком — подходит для больших файлов; ещё эффективнее xSendFile(), если веб-сервер поддерживает X-Sendfile.

Чтобы ответ ушёл немедленно и к нему не добавился вывод последующего кода.

16Сессии и куки

Компонент session, хранилища сессий (файлы, БД, кеш, Redis), flash-сообщения, чтение и отправка кук, валидация кук подписью.

Сессии и куки хранят данные между запросами. Yii оборачивает $_SESSION и $_COOKIE в объекты: сессия открывается сама при первом обращении, куки подписываются секретным ключом и не могут быть подделаны на клиенте.

Сессия#

PHP
$session = Yii::$app->session;

$session->isActive;   $session->open();   $session->close();   $session->destroy();

// три эквивалентных способа — компонент сам откроет сессию
$session->set('language', 'ru');    $session['language'] = 'ru';    $_SESSION['language'] = 'ru';
$session->get('language');          $session['language'];
$session->remove('language');       unset($session['language']);
$session->has('language');          isset($session['language']);
foreach ($session as $name => $value) { }

Хранилища#

По умолчанию сессии — файлы на сервере. Для нескольких серверов или высокой нагрузки:

Класс Где хранит
yii\web\Sessionфайлы PHP (по умолчанию)
yii\web\DbSessionтаблица БД
yii\web\CacheSessionкомпонент кеша (с оговоркой: кеш может вытеснять данные)
yii\redis\SessionRedis — рекомендуемый вариант, если он есть
yii\mongodb\SessionMongoDB
PHP
'session' => [
    'class' => 'yii\web\DbSession',
    // 'db' => 'db', 'sessionTable' => 'session',
],
SQL
CREATE TABLE session (
    id CHAR(40) NOT NULL PRIMARY KEY,   -- 64, если session.hash_function = sha256
    expire INTEGER,
    data BLOB                           -- LONGBLOB в MySQL, BYTEA в PostgreSQL
)

API у всех классов одинаковый — хранилище меняется конфигурацией, без правки кода.

Flash-сообщения#

Живут ровно до следующего запроса — идеальны для «Сохранено» после редиректа:

PHP
// запрос 1
Yii::$app->session->setFlash('success', 'Пост удалён.');
Yii::$app->session->addFlash('alerts', 'Ещё одно сообщение');   // накапливает в массив

// запрос 2
Yii::$app->session->getFlash('success');      // 'Пост удалён.'
Yii::$app->session->getFlash('alerts');       // массив
Yii::$app->session->hasFlash('success');
Yii::$app->session->getAllFlashes();

// запрос 3: сообщений уже нет

Не смешивайте setFlash() и addFlash() для одного ключа — первый хранит значение, второй массив. В шаблоне basic flash-сообщения выводит виджет Alert.

Куки#

Две коллекции: входящие — в request, исходящие — в response.

PHP
// чтение
$cookies = Yii::$app->request->cookies;
$language = $cookies->getValue('language', 'en');
if (($cookie = $cookies->get('language')) !== null) { $language = $cookie->value; }
$cookies->has('language');   isset($cookies['language']);

// отправка
$cookies = Yii::$app->response->cookies;
$cookies->add(new \yii\web\Cookie([
    'name' => 'language',
    'value' => 'ru',
    'expire' => time() + 86400 * 365,
    // 'domain', 'path', 'secure', 'httpOnly' (по умолчанию true), 'sameSite'
]));
$cookies->remove('language');   unset($cookies['language']);

Валидация кук#

Каждая кука, записанная через response, подписывается ключом; изменённая на клиенте кука просто не попадёт в request->cookies. Ключ обязателен:

PHP
'request' => [
    'cookieValidationKey' => 'длинная-случайная-строка',   // не коммитьте в репозиторий
],

Отключать enableCookieValidation не рекомендуется. Куки, прочитанные напрямую из $_COOKIE или записанные setcookie(), валидацию не проходят.

Проверь себя5

Нет — компонент открывает сессию автоматически при первом обращении. Только при прямой работе с $_SESSION нужен open().

ArrayAccess возвращает копию массива; изменение копии не попадает в сессию. Записывайте массив целиком или используйте плоские ключи.

Устанавливается в одном запросе, доступно в следующем, после чего удаляется автоматически.

Подпись не совпадёт, и request->cookies куку не вернёт; в $_COOKIE она по-прежнему есть.

Общее для всех узлов: БД (DbSession) или лучше Redis (yii\redis\Session); файлы не подходят.

17Ошибки и логирование

Обработчик ошибок и своя страница ошибки, HTTP-исключения, формат ошибок для API; уровни и категории логов, цели (файл, БД, email), фильтрация и профилирование.

Yii превращает все PHP-ошибки в исключения, показывает их с исходным кодом в режиме отладки и красивой страницей в проде. Логи пишутся через Yii::error()/warning()/info()/debug() и разводятся целями по файлам, базе, почте.

Обработка ошибок#

Компонент errorHandler включён по умолчанию (отключается константой YII_ENABLE_ERROR_HANDLER = false).

  • Не фатальные ошибки (warning, notice) становятся yii\base\ErrorException — их можно ловить.
  • При YII_DEBUG = true показывается стек вызовов и код; при false — только сообщение.
  • Исключения-наследники yii\base\UserException показываются без стека всегда: это «ошибки пользователя».
PHP
try {
    10 / 0;
} catch (\yii\base\ErrorException $e) {
    Yii::warning('Деление на ноль');
}

throw new \yii\web\NotFoundHttpException('Такой страницы нет');   // → 404 с правильным статусом

Своя страница ошибки#

Лучший способ — действие контроллера:

PHP
'components' => [
    'errorHandler' => [
        'errorAction' => 'site/error',
        // 'maxSourceLines' => 20,  'errorView', 'exceptionView' — свои шаблоны
    ],
],
PHP
public function actions()
{
    return ['error' => ['class' => 'yii\web\ErrorAction']];   // рендерит views/site/error.php
}

В представлении доступны $name, $message, $exception (с statusCode). Шаблоны basic и advanced уже содержат это действие.

Ошибки в формате API#

Если response->format не HTML, обработчик кладёт в response->data массив с полями name, message, code, status — он уходит как JSON/XML. Формат можно менять на событии beforeSend:

PHP
'response' => [
    'on beforeSend' => function ($event) {
        $response = $event->sender;
        if ($response->data !== null) {
            $response->data = ['success' => $response->isSuccessful, 'data' => $response->data];
            $response->statusCode = 200;
        }
    },
],

Логирование#

Запись#

PHP
Yii::debug('начало расчёта', __METHOD__);      // уровень trace — только при разработке
Yii::info('пользователь вошёл', 'app\auth');
Yii::warning('неожиданное состояние');
Yii::error('не удалось отправить письмо', __METHOD__);

Второй аргумент — категория. __METHOD__ даёт app\controllers\SiteController::actionLogin — так категории образуют иерархию, по которой удобно фильтровать (yii\db\*). Сообщение может быть строкой, массивом или объектом (будет экспортирован VarDumper).

Цели#

Компонент log должен быть в bootstrap, чтобы ловить сообщения с самого начала запроса:

PHP
return [
    'bootstrap' => ['log'],
    'timeZone' => 'Europe/Moscow',
    'components' => [
        'log' => [
            'traceLevel' => YII_DEBUG ? 3 : 0,   // сколько строк стека добавлять к сообщению
            'flushInterval' => 1000,             // передавать целям каждые N сообщений (и в конце запроса)
            'targets' => [
                'file' => [
                    'class' => 'yii\log\FileTarget',
                    'levels' => ['error', 'warning'],
                    'categories' => ['yii\db\*', 'yii\web\HttpException:*'],
                    'except' => ['yii\web\HttpException:404'],
                    'logVars' => ['_SERVER'],        // какие глобальные массивы прикладывать; [] — никакие
                    'maskVars' => ['_SERVER.HTTP_X_PASSWORD', '_SERVER.*_SECRET'],
                    'exportInterval' => 1000,        // писать в хранилище каждые N отфильтрованных сообщений
                    'prefix' => function ($message) {
                        return '[' . (Yii::$app->user->id ?? '-') . ']';
                    },
                ],
                'email' => [
                    'class' => 'yii\log\EmailTarget',
                    'levels' => ['error'],
                    'categories' => ['yii\db\*'],
                    'message' => [
                        'from' => ['log@example.com'], 'to' => ['admin@example.com'], 'subject' => 'Ошибки БД',
                    ],
                ],
            ],
        ],
    ],
];
Цель Куда
FileTargetruntime/log/app.log (ротация по размеру)
DbTargetтаблица БД (yii migrate --migrationPath=@yii/log/migrations)
EmailTargetписьмо через mailer
SyslogTargetсистемный syslog()

Уровни: error, warning, info, trace (это Yii::debug()), profile. Без levels цель берёт всё. Формат строки: время [IP][ID пользователя][ID сессии][уровень][категория] текст.

Отключить цель на лету: Yii::$app->log->targets['file']->enabled = false. Своя цель — наследник yii\log\Target с методом export().

Профилирование#

PHP
Yii::beginProfile('import');
    // код
    Yii::beginProfile('parse');   // вложенные пары должны быть сбалансированы
    Yii::endProfile('parse');
Yii::endProfile('import');

Результаты — сообщения уровня profile; их показывает панель отладчика. Так, например, yii\db\Command замеряет каждый SQL-запрос.

Проверь себя5

Указать errorAction => 'site/error' у errorHandler и объявить действие error через yii\web\ErrorAction с представлением views/site/error.php.

Компоненты создаются лениво; без bootstrap сообщения, записанные до первого обращения к log, могли бы потеряться.

Цель с levels => ['error'], categories => ['yii\db\*']; для 404 используют except => ['yii\web\HttpException:404'].

Уровнем: debug пишет trace-сообщения для разработки, info — информационные, которые уместны и в проде.

Сколько уровней стека вызовов прикладывать к каждому сообщению; дорого, поэтому включают только при YII_DEBUG.

🧠 Ключевые концепции

18Компоненты и свойства

BaseObject и Component — два базовых класса Yii; свойства через геттеры и сеттеры, соглашения о конструкторе и init(), жизненный цикл объекта.

Почти каждый класс Yii наследует yii\base\BaseObject (свойства через геттеры/сеттеры + конфигурация массивом) или yii\base\Component (то же + события и поведения). Понимая эти два класса, вы понимаете, как устроен весь фреймворк.

BaseObject или Component?#

BaseObject
  • свойства через getX()/setX();
  • конфигурация через массив $config;
  • лёгкий — как обычный PHP-объект.

Для структур данных, значений, хелперов-объектов.

Component
  • всё из BaseObject;
  • события: on(), off(), trigger();
  • поведения: behaviors(), attachBehavior();
  • чуть дороже по памяти и времени.

Для всего, что «живёт»: компоненты приложения, модели, контроллеры, виджеты.

Свойства через геттеры и сеттеры#

Метод getLabel() создаёт свойство label для чтения, setLabel() — для записи:

PHP
namespace app\components;

use yii\base\BaseObject;

class Foo extends BaseObject
{
    private $_label;

    public function getLabel()
    {
        return $this->_label;
    }

    public function setLabel($value)
    {
        $this->_label = trim($value);   // логика в одном месте, а не во всех точках присваивания
    }
}

$foo->label = '  Hello ';   // вызовет setLabel()
echo $foo->label;           // вызовет getLabel() → 'Hello'

Правила, о которых стоит помнить:

  • Есть только геттер — свойство только для чтения; запись бросит InvalidCallException. Только сеттер — только для записи.
  • Имена регистронезависимы ($obj->label и $obj->Label — одно и то же), потому что так работают методы PHP.
  • Если есть публичное поле с тем же именем, оно побеждает: сеттер не вызывается.
  • Модификаторы видимости геттеров не влияют на «свойство».
  • Только нестатические методы.
  • property_exists() таких свойств не видит — используйте canGetProperty() / canSetProperty().

Соглашения о конструкторе и init()#

PHP
namespace app\components;

use yii\base\BaseObject;

class MyClass extends BaseObject
{
    public $prop1;
    public $prop2;

    public function __construct($param1, $param2, $config = [])
    {
        // до применения конфигурации: значения по умолчанию
        parent::__construct($config);    // родительский конструктор — последним
    }

    public function init()
    {
        parent::init();                  // родительский init() — первым
        // после применения конфигурации: проверка, нормализация свойств
    }
}

$obj = new MyClass(1, 2, ['prop1' => 3, 'prop2' => 4]);
$obj = \Yii::createObject(['class' => MyClass::class, 'prop1' => 3], [1, 2]);   // через DI-контейнер

Три правила: $config — последний параметр конструктора; parent::__construct($config) — в конце своего конструктора; parent::init() — в начале своего init().

Жизненный цикл объекта#

  1. Конструктор — предварительная инициализация, значения по умолчанию.
  2. Применение $config — свойства из массива перезаписывают умолчания (через сеттеры или публичные поля).
  3. init() — постинициализация: проверить готовность, нормализовать.
  4. Работа — вызовы методов.

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

Проверь себя4

BaseObject — если нужны только свойства и конфигурация; Component — если объект должен генерировать события или принимать поведения.

InvalidCallException — свойство доступно только для чтения.

Конструктор родителя применяет конфигурацию и вызывает init(); свои умолчания надо задать до этого, а в init() сначала дать отработать родителю, чтобы объект был инициализирован.

$obj->canGetProperty('label') / canSetProperty('label'); property_exists() геттеры не видит.

19События

Как подписаться на событие, вызвать своё, передать данные, остановить цепочку обработчиков; события уровня класса, интерфейса и глобальные.

Событие — точка, в которой компонент говорит «случилось вот это», а любой код может отреагировать. Компонент вызывает trigger(), подписчики регистрируются через on(). Так фреймворк даёт встраиваться в свою работу, не требуя наследования.

Подписка#

PHP
$foo->on(Foo::EVENT_HELLO, 'function_name');                 // глобальная функция
$foo->on(Foo::EVENT_HELLO, [$object, 'methodName']);         // метод объекта
$foo->on(Foo::EVENT_HELLO, ['app\components\Bar', 'method']); // статический метод
$foo->on(Foo::EVENT_HELLO, function ($event) {               // замыкание
    echo $event->name;       // имя события
    echo $event->sender;     // кто вызвал trigger()
    echo $event->data;       // данные, переданные при подписке
});

// данные для обработчика — третий аргумент
$foo->on(Foo::EVENT_HELLO, function ($event) { echo $event->data; }, 'abc');

// в начало очереди, а не в конец
$foo->on(Foo::EVENT_HELLO, $handler, null, false);

Через конфигурацию — ключ 'on имяСобытия':

PHP
'components' => [
    'db' => [
        'class' => 'yii\db\Connection',
        'on afterOpen' => function ($event) {
            $event->sender->createCommand("SET time_zone = '+00:00'")->execute();
        },
    ],
],

Порядок и остановка#

Обработчики вызываются в порядке подписки. Чтобы остановить остальные:

PHP
$foo->on(Foo::EVENT_HELLO, function ($event) {
    $event->handled = true;   // следующие обработчики не вызовутся
});

Многие события фреймворка отменяют операцию через $event->isValid = false (beforeSave, beforeAction, beforeLogin…).

Вызов своего события#

PHP
namespace app\components;

use yii\base\Component;
use yii\base\Event;

class MessageEvent extends Event
{
    public $message;           // свои данные — в наследнике Event
}

class Mailer extends Component
{
    const EVENT_MESSAGE_SENT = 'messageSent';   // имя — константой: без опечаток, видно в IDE

    public function send($message)
    {
        // ...отправка...
        $this->trigger(self::EVENT_MESSAGE_SENT, new MessageEvent(['message' => $message]));
    }
}

Класс должен наследовать yii\base\Component. Без объекта события trigger() создаст пустой Event.

Отписка#

PHP
$foo->off(Foo::EVENT_HELLO, 'function_name');
$foo->off(Foo::EVENT_HELLO, $anonymousFunction);   // замыкание нужно сохранить в переменную при подписке
$foo->off(Foo::EVENT_HELLO);                        // все обработчики события

События уровня класса#

Слушать событие всех экземпляров класса и его потомков — статический Event::on():

PHP
use yii\base\Event;
use yii\db\ActiveRecord;

Event::on(ActiveRecord::class, ActiveRecord::EVENT_AFTER_INSERT, function ($event) {
    Yii::debug(get_class($event->sender) . ' добавлен');
});

Event::trigger(Foo::class, Foo::EVENT_HELLO);    // событие без объекта: $event->sender === null
Event::off(Foo::class, Foo::EVENT_HELLO);

Порядок: сначала обработчики экземпляра, затем — класса. Осторожно с базовыми классами вроде BaseObject: подписка на них сработает для всего приложения.

На уровне интерфейса#

Первым аргументом Event::on() может быть интерфейс — обработчик получит события всех классов, которые его реализуют:

PHP
interface DanceEventInterface { const EVENT_DANCE = 'dance'; }

Event::on(DanceEventInterface::class, DanceEventInterface::EVENT_DANCE, function ($event) {
    Yii::debug(get_class($event->sender) . ' танцует');
});

Вызвать Event::trigger() для интерфейса нельзя — только для конкретного класса.

Глобальные события#

Никакого отдельного механизма: событие вешают на синглтон — приложение:

PHP
Yii::$app->on('app.mail.sent', function ($event) { /* ... */ });
Yii::$app->trigger('app.mail.sent', new Event(['sender' => $mailer]));

Пространство имён одно на всё приложение — давайте событиям осмысленные префиксы.

Где события уже есть#

Приложение
beforeRequest, afterRequest, beforeAction, afterAction
Контроллер / модуль
beforeAction, afterAction
Model
beforeValidate, afterValidate
ActiveRecord
init, afterFind, beforeInsert/Update/Delete, afterInsert/Update/Delete, afterRefresh
View
beforeRender, afterRender, beginPage, endPage, beginBody, endBody
User
beforeLogin, afterLogin, beforeLogout, afterLogout
Response
beforeSend, afterPrepare, afterSend
Connection
afterOpen, beginTransaction, commitTransaction, rollbackTransaction
Проверь себя5

Наследовать yii\base\Component и вызывать $this->trigger($name, $event).

Установить $event->handled = true в обработчике.

Первый слушает конкретный объект; второй — все экземпляры класса и его потомков (и вызывается после обработчиков экземпляра).

Аргументом $data: третьим у $obj->on() и четвёртым у Event::on(); они доступны как $event->data. Данные, известные в момент события, передают через свой класс Event в trigger().

Ключом 'on имяСобытия' => $handler в массиве конфигурации компонента.

20Поведения

Поведения (миксины Yii) добавляют компоненту методы, свойства и реакции на события без наследования; встроенные TimestampBehavior, BlameableBehavior, SluggableBehavior и сравнение с трейтами.

Поведение — объект yii\base\Behavior, «прикреплённый» к компоненту: его публичные методы и свойства становятся методами и свойствами компонента, а events() подписывает его на события владельца. Самый известный пример — TimestampBehavior, который сам проставляет created_at/updated_at.

Как выглядит поведение#

PHP
namespace app\components;

use yii\base\Behavior;
use yii\db\ActiveRecord;

class MyBehavior extends Behavior
{
    public $prop1;
    private $_prop2;

    public function getProp2() { return $this->_prop2; }
    public function setProp2($value) { $this->_prop2 = $value; }

    public function foo()
    {
        return $this->owner->id;          // $this->owner — компонент, к которому прикреплены
    }

    public function events()             // реакция на события владельца
    {
        return [
            ActiveRecord::EVENT_BEFORE_VALIDATE => 'beforeValidate',
        ];
    }

    public function beforeValidate($event)
    {
        // ...
    }
}

Обработчик в events() — имя метода поведения, массив [$obj, 'method'] или замыкание.

Прикрепление#

PHP
class User extends ActiveRecord
{
    public function behaviors()
    {
        return [
            MyBehavior::class,                              // анонимное
            'myBehavior2' => MyBehavior::class,             // именованное
            [                                               // с конфигурацией
                'class' => MyBehavior::class,
                'prop1' => 'value1',
            ],
            'myBehavior4' => ['class' => MyBehavior::class, 'prop1' => 'value1'],
        ];
    }
}

// динамически
$component->attachBehavior('myBehavior', new MyBehavior());
$component->attachBehaviors(['a' => MyBehavior::class, 'b' => ['class' => MyBehavior::class]]);
$component->detachBehavior('myBehavior');
$component->detachBehaviors();

// в конфигурации — ключ 'as имя'
'as myBehavior' => ['class' => MyBehavior::class, 'prop1' => 'value1'],

После прикрепления:

PHP
$component->prop1 = 'x';          // свойство поведения
$component->foo();                // метод поведения
$component->getBehavior('myBehavior2');
$component->getBehaviors();

Если два поведения объявили одноимённый метод, побеждает то, что прикреплено раньше.

Встроенные поведения#

TimestampBehavior#

PHP
use yii\behaviors\TimestampBehavior;
use yii\db\Expression;

public function behaviors()
{
    return [
        [
            'class' => TimestampBehavior::class,
            'attributes' => [                       // так и по умолчанию — можно не писать
                ActiveRecord::EVENT_BEFORE_INSERT => ['created_at', 'updated_at'],
                ActiveRecord::EVENT_BEFORE_UPDATE => ['updated_at'],
            ],
            // 'value' => new Expression('NOW()'),  // если столбцы DATETIME, а не INT с unix timestamp
        ],
    ];
}

$user->touch('login_at');   // проставить текущее время в атрибут и сохранить

Другие#

BlameableBehavior
записывает ID текущего пользователя в created_by / updated_by
SluggableBehavior
строит URL-slug из атрибута ('attribute' => 'title'), с опциями уникальности и неизменности
AttributeBehavior
общий случай: значение (или замыкание) в атрибуты по событиям
AttributeTypecastBehavior
приведение типов атрибутов при валидации/сохранении
OptimisticLockBehavior
оптимистичная блокировка через версию (см. Active Record)
yii2tech\ar\softdelete\SoftDeleteBehavior
«мягкое» удаление (стороннее)

Поведения против трейтов#

Поведения
  • наследуются, настраиваются конфигурацией;
  • прикрепляются и отвязываются на лету, без правки класса;
  • умеют слушать события владельца;
  • конфликты имён решаются порядком прикрепления.
Трейты
  • быстрее: нет дополнительных объектов;
  • поддерживаются IDE как конструкция языка;
  • требуют правки класса и ручного разрешения конфликтов.

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

Проверь себя4

Через свойство $this->owner, которое устанавливается при attachBehavior().

Массив «событие владельца → обработчик»; так TimestampBehavior подписывается на beforeInsert/beforeUpdate и заполняет даты.

Это объект: его можно настроить, прикрепить и отвязать во время выполнения, и оно может реагировать на события.

Ключом 'as имя' => [...] в массиве конфигурации компонента.

21Конфигурации, псевдонимы, автозагрузка

Формат конфигурации (class, свойства, on, as), Yii::createObject() и Yii::configure(), конфигурационные файлы, константы окружения; псевдонимы путей @app/@web/@runtime; правила автозагрузки классов и карта классов.

В Yii объекты описывают массивами: класс, начальные свойства, обработчики событий, поведения. Такой массив передают в Yii::createObject(), в конструктор или в конфигурацию приложения. Пути и URL в конфигурации пишут псевдонимами (@app/runtime), а классы находятся автозагрузчиком по пространству имён — и всё это связано между собой.

Формат конфигурации#

PHP
$config = [
    'class' => 'app\components\SearchEngine',   // полное имя класса
    'apiKey' => 'xxxxxxxx',                      // публичное поле или свойство через сеттер
    'on search' => function ($event) {           // обработчик события
        Yii::info('Искали: ' . $event->keyword);
    },
    'as indexer' => [                            // поведение
        'class' => 'app\components\IndexerBehavior',
    ],
];

$engine = Yii::createObject($config);            // создать (через DI-контейнер)
Yii::configure($engine, ['apiKey' => 'yyy']);    // настроить существующий объект (без ключа class)

Так описываются компоненты приложения, модули, виджеты, поведения, валидаторы, цели логов — везде один формат. Для виджетов ключ class не нужен — класс уже известен:

PHP
echo Menu::widget([
    'activateItems' => false,
    'items' => [
        ['label' => 'Home', 'url' => ['site/index']],
        ['label' => 'Login', 'url' => ['site/login'], 'visible' => Yii::$app->user->isGuest],
    ],
]);

Конфигурация приложения#

Самая большая: свойства приложения плюс components, modules, params. Обычно разбита на файлы, каждый возвращает массив:

config/web.phpPHP
$params = require __DIR__ . '/params.php';
$db = require __DIR__ . '/db.php';

$config = [
    'id' => 'basic',
    'basePath' => dirname(__DIR__),
    'bootstrap' => ['log'],
    'aliases' => ['@bower' => '@vendor/bower-asset', '@npm' => '@vendor/npm-asset'],
    'components' => [
        'request' => ['cookieValidationKey' => '...'],
        'cache' => ['class' => 'yii\caching\FileCache'],
        'user' => ['identityClass' => 'app\models\User', 'enableAutoLogin' => true],
        'errorHandler' => ['errorAction' => 'site/error'],
        'log' => [
            'traceLevel' => YII_DEBUG ? 3 : 0,
            'targets' => [['class' => 'yii\log\FileTarget', 'levels' => ['error', 'warning']]],
        ],
        'db' => $db,
    ],
    'params' => $params,
];

if (YII_ENV_DEV) {
    $config['bootstrap'][] = 'debug';
    $config['modules']['debug'] = 'yii\debug\Module';
    $config['bootstrap'][] = 'gii';
    $config['modules']['gii'] = 'yii\gii\Module';
}

return $config;

Ключа class нет — класс задаёт входной скрипт: new yii\web\Application($config).

Умолчания для классов (DI)#

Через свойство container (с 2.0.11) или Yii::$container->set() задаются значения, которые применятся ко всем объектам класса, созданным через Yii::createObject():

PHP
'container' => [
    'definitions' => [
        'yii\widgets\LinkPager' => ['maxButtonCount' => 5],
        'yii\mail\MailerInterface' => 'yii\symfonymailer\Mailer',
    ],
    'singletons' => [],
],

Подробнее — в разделе DI-контейнер.

Константы окружения#

YII_ENV во входном скрипте порождает YII_ENV_PROD, YII_ENV_DEV, YII_ENV_TEST — по ним ветвят конфигурацию (как debug и gii выше). В advanced-шаблоне для этого есть папка environments/ и php init.

Псевдонимы#

Псевдоним начинается с @ и заменяет абсолютный путь или URL:

PHP
Yii::setAlias('@foo', '/path/to/foo');              // корневой псевдоним
Yii::setAlias('@bar', 'https://www.example.com');
Yii::setAlias('@foobar', '@foo/bar');                // на основе другого

Yii::getAlias('@foo/bar/file.php');                  // /path/to/foo/bar/file.php — производный
Yii::getAlias('@bar');                               // https://www.example.com

getAlias() не проверяет, что путь существует. Корневой псевдоним может содержать / — берётся самое длинное совпадение (@foo/bar приоритетнее @foo).

Задают их обычно в конфигурации: 'aliases' => ['@uploads' => '@webroot/uploads']. Большинство свойств фреймворка принимают псевдонимы напрямую: 'cachePath' => '@runtime/cache'.

Предопределённые#

Псевдоним Указывает на
@yiiпапка фреймворка (vendor/yiisoft/yii2)
@appbasePath приложения
@runtime@app/runtime
@vendor@app/vendor
@webrootпапка с index.php (только web)
@webбазовый URL приложения (только web)
@bower, @npm@vendor/bower-asset, @vendor/npm-asset (в шаблонах)
@yii/jui, @yii/bootstrap…папки установленных расширений — из их composer.json

В advanced-шаблоне дополнительно @common, @frontend, @backend, @console.

Автозагрузка классов#

Автозагрузчик Yii (PSR-4-совместимый, регистрируется в Yii.php) находит файл по двум правилам:

  1. класс в пространстве имён: foo\bar\MyClass;
  2. путь = псевдоним из имени класса: @foo/bar/MyClass.php — значит, @foo (или @foo/bar) должен быть корневым псевдонимом.

Поэтому app\controllers\SiteController живёт в @app/controllers/SiteController.php, а классы расширений находятся через их псевдонимы. Чтобы подключить библиотеку без автозагрузчика, достаточно объявить псевдоним для её корневого пространства имён.

PHP
Yii::$classMap['foo\bar\MyClass'] = '@app/lib/MyClass.php';   // карта классов: без поиска, самый быстрый путь

Все классы самого фреймворка загружаются через карту классов. Автозагрузчик Composer подключается до Yii.php, чтобы автозагрузчик Yii был первым в очереди.

Проверь себя5

'on событие' => обработчик подписывает на событие объекта, 'as имя' => конфиг прикрепляет поведение.

createObject() идёт через DI-контейнер: применяет зарегистрированные умолчания и разрешает зависимости конструктора.

Путь всё равно вернётся — getAlias() не проверяет существование файлов.

@app — предопределённый псевдоним, а автозагрузчик ищет @app/components/MyClass.php по пространству имён.

Обернуть его подключение в if (YII_ENV_DEV) { ... } в конфигурации.

22Service Locator и DI-контейнер

Yii::$app как локатор сервисов, Yii::$container как контейнер внедрения зависимостей: регистрация, разрешение через конструктор и сеттеры, синглтоны, Instance::of(), практические сценарии.

Service Locator (Yii::$app, любой модуль) хранит именованные компоненты и отдаёт их по ID. DI-контейнер (Yii::$container) умеет создавать объекты, сам подставляя зависимости из типов конструктора. Локатор построен поверх контейнера: когда Yii::$app->db создаётся впервые, объект собирает контейнер.

Service Locator#

PHP
use yii\di\ServiceLocator;

$locator = new ServiceLocator();
$locator->set('cache', 'yii\caching\ApcCache');                    // имя класса
$locator->set('db', ['class' => 'yii\db\Connection', 'dsn' => '…']);   // конфигурация
$locator->set('search', function () { return new SolrService('127.0.0.1'); });   // фабрика
$locator->set('pageCache', new FileCache());                       // готовый объект

$cache = $locator->get('cache');   // создаётся при первом обращении, дальше тот же экземпляр
$cache = $locator->cache;          // то же самое — через магическое свойство
$locator->has('cache');            // зарегистрирован ли
$locator->get('nope');             // исключение

Свойство components в конфигурации приложения — это setComponents() локатора. Для обёрток над сторонними библиотеками удобно прятать сложную инициализацию за статической фабрикой, которая возвращает замыкание:

PHP
class SolrServiceBuilder
{
    public static function build($ip)
    {
        return function () use ($ip) {
            $solr = new app\components\SolrService($ip);
            // ...инициализация...
            return $solr;
        };
    }
}

'components' => ['search' => SolrServiceBuilder::build('127.0.0.1')],

DI-контейнер#

Виды внедрения#

PHP
// 1. Через конструктор: контейнер видит тип и создаёт зависимость сам
class Foo
{
    public function __construct(Bar $bar) { }
}
$foo = $container->get('Foo');   // == new Foo(new Bar())

// 2. Через метод: зависимость нужна одному методу
Yii::$container->invoke([$obj, 'doSomething'], ['param1' => 42]);   // остальные параметры по типам

// 3. Через сеттер/свойство: конфигурация третьим аргументом
$container->get('Foo', [], ['bar' => $container->get('Bar'), 'qux' => $container->get('Qux')]);

// 4. Через callback: полный контроль над созданием
$container->set('Foo', function ($container, $params, $config) {
    return new Foo(new Bar());
});

Регистрация#

PHP
$container = Yii::$container;

$container->set('yii\db\Connection');                              // класс как есть (можно не регистрировать)
$container->set('yii\mail\MailerInterface', 'yii\symfonymailer\Mailer');   // интерфейс → реализация
$container->set('foo', 'yii\db\Connection');                       // псевдоним
$container->set('yii\db\Connection', ['dsn' => '…', 'username' => 'root']);   // класс + конфигурация
$container->set('db', ['class' => 'yii\db\Connection', 'dsn' => '…']);       // псевдоним + конфигурация
$container->set('pageCache', new FileCache());                     // экземпляр
$container->setSingleton('yii\db\Connection', ['dsn' => '…']);     // один экземпляр на всё приложение

set() создаёт новый объект при каждом get(), setSingleton() — один раз.

Разрешение#

PHP
$db = $container->get('db');
$engine = $container->get('app\components\SearchEngine', [$apiKey, $apiSecret], ['type' => 1]);
//         == new SearchEngine($apiKey, $apiSecret, ['type' => 1])
$api = $container->get('app\components\Api', ['host' => $host, 'apiKey' => $apiKey]);   // именованные параметры

Разрешение рекурсивно: контейнер смотрит на типы параметров конструктора и создаёт всю цепочку.

PHP
interface UserFinderInterface { function findUser(); }

class UserFinder extends BaseObject implements UserFinderInterface
{
    public $db;
    public function __construct(Connection $db, $config = [])
    {
        $this->db = $db;
        parent::__construct($config);
    }
    public function findUser() { }
}

class UserLister extends BaseObject
{
    public $finder;
    public function __construct(UserFinderInterface $finder, $config = [])
    {
        $this->finder = $finder;
        parent::__construct($config);
    }
}

$container->set('yii\db\Connection', ['dsn' => '...']);
$container->set('app\models\UserFinderInterface', ['class' => 'app\models\UserFinder']);
$container->set('userLister', 'app\models\UserLister');

$lister = $container->get('userLister');
// == $db = new Connection(['dsn' => '...']); $finder = new UserFinder($db); $lister = new UserLister($finder);

Массовая регистрация и Instance::of()#

PHP
use yii\di\Instance;

$container->setSingletons([
    'tempFileStorage' => [
        ['class' => 'app\storage\FileStorage'],   // определение
        ['/var/tempfiles'],                        // параметры конструктора
    ],
]);

$container->setDefinitions([
    'yii\web\Request' => 'app\components\Request',
    'yii\web\Response' => ['class' => 'app\components\Response', 'format' => 'json'],
    'app\storage\DocumentsReader' => [
        ['class' => 'app\storage\DocumentsReader'],
        [Instance::of('tempFileStorage')],          // ссылка на зарегистрированную зависимость
    ],
]);

То же самое декларативно в конфигурации приложения — свойство container с ключами definitions и singletons.

Практика#

Умолчания для виджетов и любых классов#

PHP
Yii::$container->set('yii\widgets\LinkPager', ['maxButtonCount' => 5]);
echo LinkPager::widget();                          // 5 кнопок
echo LinkPager::widget(['maxButtonCount' => 20]);  // явное значение сильнее

Работает для всего, что создаётся через Yii::createObject() — а это почти всё ядро.

Зависимости в контроллере#

PHP
namespace app\controllers;

use app\components\BookingInterface;
use yii\web\Controller;

class HotelController extends Controller
{
    protected $bookingService;

    public function __construct($id, $module, BookingInterface $bookingService, $config = [])
    {
        $this->bookingService = $bookingService;
        parent::__construct($id, $module, $config);
    }
}

Без регистрации будет ошибка «cannot instantiate BookingInterface». Регистрируем реализацию — и контейнер подставит её третьим аргументом:

PHP
Yii::$container->set('app\components\BookingInterface', 'app\components\BookingService');
Проверь себя5

Локатор отдаёт компоненты по строковому ID (Yii::$app->db); контейнер создаёт объекты по классу/интерфейсу, автоматически разрешая зависимости конструктора. Локатор использует контейнер для создания своих компонентов.

Когда объект не хранит изменяемого состояния и его дорого создавать — например, обёртка над файловым хранилищем или соединение.

Чтобы в параметрах конструктора сослаться на другую зарегистрированную зависимость по имени — контейнер подставит её при создании.

Ошибка создания: контейнер не знает, какой класс подставить. Нужно Yii::$container->set(Interface::class, Impl::class).

Yii::$container->set('yii\widgets\LinkPager', ['maxButtonCount' => 5]) или container.definitions в конфигурации.

🗄 Базы данных

23DAO — работа с SQL

Подключение к БД, выполнение запросов с привязкой параметров, insert/update/delete/batchInsert, экранирование имён и префиксы таблиц, транзакции, репликация master/slave, работа со схемой.

DAO (Data Access Objects) — тонкий объектный слой над PDO: Connection → createCommand($sql) → queryAll()/execute(). Самый быстрый способ работать с БД, на нём построены Query Builder и Active Record. Поддерживаются MySQL/MariaDB, PostgreSQL, SQLite, MSSQL, Oracle, CUBRID.

Подключение#

config/db.phpPHP
return [
    'class' => 'yii\db\Connection',
    'dsn' => 'mysql:host=localhost;dbname=example',
    'username' => 'root',
    'password' => '',
    'charset' => 'utf8mb4',
    // 'tablePrefix' => 'tbl_',
    // 'enableSchemaCache' => true, 'schemaCacheDuration' => 3600,   // см. Производительность
    'on afterOpen' => function ($event) {
        $event->sender->createCommand("SET time_zone = '+00:00'")->execute();   // инициализация сессии
    },
];
СУБД DSN
MySQL, MariaDBmysql:host=localhost;dbname=mydb
PostgreSQLpgsql:host=localhost;port=5432;dbname=mydb
SQLitesqlite:/path/to/database.sqlite
MS SQL Serversqlsrv:Server=localhost;Database=mydb (или dblib:, mssql:)
Oracleoci:dbname=//localhost:1521/mydb
через ODBCodbc:Driver={MySQL};Server=… + 'driverName' => 'mysql'

Реальное соединение открывается лениво — при первом запросе или open(). Компонентов может быть несколько (db, db2).

Запросы#

PHP
$db = Yii::$app->db;

$posts  = $db->createCommand('SELECT * FROM post')->queryAll();            // массив строк (ассоц. массивы)
$post   = $db->createCommand('SELECT * FROM post WHERE id=1')->queryOne(); // одна строка или false
$titles = $db->createCommand('SELECT title FROM post')->queryColumn();     // первый столбец
$count  = $db->createCommand('SELECT COUNT(*) FROM post')->queryScalar();  // одно значение или false

$db->createCommand('UPDATE post SET status=1 WHERE id=1')->execute();       // число затронутых строк

Значения приходят строками, даже из числовых столбцов — так PDO сохраняет точность.

Привязка параметров#

Единственно правильный способ подставлять данные в SQL:

PHP
$post = $db->createCommand('SELECT * FROM post WHERE id=:id AND status=:status')
    ->bindValue(':id', $id)
    ->bindValue(':status', 1)
    ->queryOne();

$post = $db->createCommand('SELECT * FROM post WHERE id=:id', [':id' => $id])->queryOne();   // сразу

// подготовленный запрос многократно
$command = $db->createCommand('SELECT * FROM post WHERE id=:id');
$post1 = $command->bindValue(':id', 1)->queryOne();
$post2 = $command->bindValue(':id', 2)->queryOne();

// по ссылке: значение читается в момент выполнения
$command = $db->createCommand('SELECT * FROM post WHERE id=:id')->bindParam(':id', $id);
$id = 1; $post1 = $command->queryOne();
$id = 2; $post2 = $command->queryOne();

INSERT, UPDATE, DELETE без SQL#

PHP
$db->createCommand()->insert('user', ['name' => 'Sam', 'age' => 30])->execute();
// один запрос
$db->createCommand()->batchInsert('user', ['name', 'age'], [['Tom', 30], ['Jane', 20]])->execute();
$db->createCommand()->update('user', ['status' => 1], 'age > 30')->execute();
// условие в формате массива
$db->createCommand()->update('user', ['status' => 1], ['id' => [1, 2, 3]])->execute();
$db->createCommand()->delete('user', 'status = 0')->execute();
// INSERT … ON DUPLICATE KEY UPDATE
$db->createCommand()->upsert('user', ['email' => 'a@b.c', 'name' => 'A'])->execute();

Эти методы только строят команду — execute() обязателен.

Экранирование имён и префикс#

PHP
$count = $db->createCommand('SELECT COUNT([[id]]) FROM {{employee}}')->queryScalar();
// MySQL: SELECT COUNT(`id`) FROM `employee`;  PostgreSQL: "id", "employee"

// с tablePrefix = 'tbl_':
$db->createCommand('SELECT * FROM {{%employee}}');   // → `tbl_employee`

[[столбец]] и {{таблица}} работают во всех СУБД, % заменяется на tablePrefix.

Транзакции#

PHP
$db->transaction(function ($db) {
    $db->createCommand($sql1)->execute();
    $db->createCommand($sql2)->execute();
});   // исключение внутри → rollback и проброс наружу

// то же с ручным контролем
$transaction = $db->beginTransaction();
try {
    $db->createCommand($sql1)->execute();
    $db->createCommand($sql2)->execute();
    $transaction->commit();
} catch (\Throwable $e) {
    $transaction->rollBack();
    throw $e;
}

Уровень изоляции — вторым аргументом transaction() / beginTransaction(): Transaction::READ_UNCOMMITTED, READ_COMMITTED, REPEATABLE_READ, SERIALIZABLE (или строка, понятная СУБД). Вложенные транзакции работают через savepoints, если СУБД их поддерживает. Ограничения: SQLite знает только два уровня, PostgreSQL меняет уровень уже после старта (setIsolationLevel()), MSSQL и SQLite задают уровень на всё соединение.

Репликация: чтение с реплик, запись в мастер#

PHP
'db' => [
    'class' => 'yii\db\Connection',
    'dsn' => 'dsn мастера', 'username' => 'master', 'password' => '',
    'slaveConfig' => [
        'username' => 'slave', 'password' => '',
        'attributes' => [PDO::ATTR_TIMEOUT => 10],   // «мёртвая» реплика определяется по таймауту
    ],
    'slaves' => [
        ['dsn' => 'dsn реплики 1'],
        ['dsn' => 'dsn реплики 2'],
    ],
    // несколько мастеров: 'masterConfig' + 'masters' => [...] (тогда dsn/username выше игнорируются)
],

Дальше всё автоматически: query*() идут на случайную живую реплику, execute() — на мастер, транзакции — на мастер. Недоступные серверы запоминаются в serverStatusCache на serverRetryInterval секунд.

PHP
$db->useMaster(function ($db) {                     // прочитать именно с мастера
    return $db->createCommand('SELECT …')->queryAll();
});
$db->enableSlaves = false;                          // всё на мастер
$transaction = $db->slave->beginTransaction();      // транзакция на реплике (редко)

Схема базы данных#

PHP
$db->createCommand()->createTable('post', [
    'id' => 'pk',
    'title' => 'string NOT NULL',
    'text' => 'text',
])->execute();
// также: renameTable, dropTable, truncateTable, addColumn, renameColumn, dropColumn, alterColumn,
// addPrimaryKey, dropPrimaryKey, addForeignKey, dropForeignKey, createIndex, dropIndex

$table = $db->getTableSchema('post');   // TableSchema: columns, primaryKey, foreignKeys
$db->schema->getTableNames();

Эти же методы, но с выводом прогресса, есть у миграций — там им и место.

Проверь себя5

PDO отдаёт все значения строками ради точности; приводите тип сами или используйте Active Record, который приводит по схеме.

Привязать параметром нельзя; проверьте значение по белому списку допустимых столбцов и оберните в [[...]].

Будет выполнен rollBack(), а исключение проброшено дальше.

На мастер: в рамках транзакции все запросы используют соединение с мастером.

Имя таблицы post с автоматической подстановкой tablePrefix и экранированием под текущую СУБД.

24Query Builder

Построение SQL-запросов методами yii\db\Query — select/from/where/join/orderBy/limit, форматы условий (строка, хеш, оператор, объект), filterWhere, агрегаты, пакетная выборка batch/each, подзапросы и Expression.

yii\db\Query собирает запрос объектно и независимо от СУБД: вы описываете что нужно, а построитель сам экранирует имена, привязывает параметры и генерирует SQL под конкретную базу. Тот же API используют ActiveQuery и Active Record, так что выучить его придётся один раз.

Скелет запроса#

PHP
use yii\db\Query;

$rows = (new Query())
    ->select(['id', 'email'])
    ->from('user')
    ->where(['status' => 1])
    ->orderBy('id')
    ->limit(10)
    ->all();                 // массив строк; ->all($db2) — через другое соединение

// посмотреть, что получилось
$sql = (new Query())->from('user')->createCommand()->getRawSql();

Методы построения возвращают $this — цепочка в любом порядке. Выполнение: all(), one(), column(), scalar(), exists(), count(), sum('col'), average(), max(), min().

SELECT и FROM#

PHP
$query->select('id, email');                              // строка — в простых случаях
$query->select(['id', 'email']);                          // массив — безопаснее, всё экранируется
$query->select(['user_id' => 'user.id', 'email']);        // псевдоним столбца
$query->select(['user.id AS user_id', 'email']);          // так тоже можно
$query->select(["CONCAT(first_name, ' ', last_name) AS full_name", 'email']);   // выражения не экранируются
$query->select(['id', 'count' => $subQuery]);             // подзапрос как столбец
$query->select('user_id')->distinct();                    // DISTINCT
$query->addSelect(['email']);                             // добавить к выбранным

$query->from('user');
$query->from(['public.user u', 'public.post p']);          // несколько таблиц с псевдонимами
$query->from(['u' => 'public.user', 'p' => 'public.post']);
$query->from(['u' => $subQuery]);                         // подзапрос в FROM

Если select() не вызван, будет SELECT *.

WHERE — три формата условий#

Строковый#

PHP
$query->where('status=1');
$query->where('status=:status', [':status' => $status]);          // параметры обязательно через плейсхолдеры
$query->where('YEAR(somedate) = 2015');
$query->where('status=:status')->addParams([':status' => $status]);

Хеш — «столбец → значение»#

PHP
$query->where([
    'status' => 10,
    'type' => null,               // → type IS NULL
    'id' => [4, 8, 15],           // → id IN (4, 8, 15)
]);
$query->where(['id' => $userQuery]);   // → id IN (SELECT id FROM user WHERE …)

Все значения привязываются параметрами. Нужен именно этот формат, когда данные пришли от пользователя.

Операторный — [оператор, операнд1, операнд2, …]#

Оператор Пример SQL
and, or['and', 'id=1', 'id=2']id=1 AND id=2
not['not', ['status' => 1]]NOT (status=1)
between, not between['between', 'id', 1, 10]id BETWEEN 1 AND 10
in, not in['in', 'id', [1, 2, 3]]id IN (1, 2, 3)
in по составному ключу['in', ['id', 'name'], [['id' => 1, 'name' => 'a']]](id, name) IN ((1, 'a'))
like, not like['like', 'name', 'tester']name LIKE '%tester%'
like c массивом['like', 'name', ['test', 'sample']]name LIKE '%test%' AND name LIKE '%sample%'
like без %['like', 'name', '%tester', false]name LIKE '%tester' (экранирование выключено)
or like, or not like['or like', 'name', ['a', 'b']]name LIKE '%a%' OR name LIKE '%b%'
exists, not exists['exists', $subQuery]EXISTS (SELECT …)
>, <, =, !=, >=, …['>=', 'id', 10]id >= 10

Операнды and/or могут быть вложенными массивами любого формата:

PHP
$query->where([
    'and',
    ['status' => 1],
    ['or', ['type' => 1], ['>', 'created_at', $ts]],
]);

Объектный (с 2.0.14)#

PHP
use yii\db\conditions\{AndCondition, BetweenCondition, InCondition, LikeCondition};

$query->where(new AndCondition([
    new InCondition('id', 'in', [1, 2, 3]),
    new BetweenCondition('created_at', 'between', $from, $to),
]));

Каждому оператору соответствует класс в yii\db\conditions\*; они удобны для программной сборки и расширяемы через QueryBuilder::conditionClasses.

Достраивание условий#

PHP
$query->where(['status' => 1])
      ->andWhere(['>', 'id', 100])      // AND (…)
      ->orWhere(['type' => 2]);         // OR (…)

// фильтр: пустые значения ('' , ' ', null, []) просто пропускаются
$query->filterWhere(['username' => $username, 'email' => $email]);
$query->andFilterWhere(['like', 'title', $search]);
$query->andFilterCompare('price', '>=100');   // разбирает оператор из строки: '>=100' → price >= 100

filterWhere — идеальный инструмент для поисковых форм: WHERE username=:u появится, только если поле заполнено.

Остальные части#

PHP
$query->orderBy(['id' => SORT_ASC, 'name' => SORT_DESC]);   // или строка 'id ASC, name DESC'
$query->orderBy(new Expression('RAND()'));
$query->addOrderBy('created_at');

$query->groupBy(['id', 'status'])->addGroupBy('type');
$query->having(['status' => 1])->andHaving(['>', 'age', 30]);   // те же форматы, что у where
$query->filterHaving([...]);

$query->limit(10)->offset(20);   // отрицательные/null — без ограничения

$query->join('LEFT JOIN', 'post', 'post.user_id = user.id');
$query->leftJoin('post', 'post.user_id = user.id');
$query->innerJoin(['p' => $subQuery], 'p.user_id = user.id');   // rightJoin тоже есть

$query1->union($query2);              // UNION; union($q, true) — UNION ALL

// WITH posts AS (…) — общие табличные выражения (2.0.35)
$query->withQuery($cte, 'posts', $recursive = false);

Выполнение и результат#

PHP
$rows  = $query->all();                     // все строки
$row   = $query->one();                     // первая строка или false; LIMIT 1 сам не добавляет
$col   = $query->column();                  // массив значений первого столбца
$value = $query->scalar();                  // первый столбец первой строки
$bool  = $query->exists();
$n     = $query->count();                   // SELECT COUNT(*) — с учётом where/join, без limit/orderBy
$sum   = $query->sum('amount'); $avg = $query->average('amount');   // max(), min()

// индексировать результат столбцом или замыканием
$users = $query->from('user')->indexBy('id')->all();                 // ['id' => row]
$users = $query->indexBy(function ($row) { return $row['id'] . $row['username']; })->all();

count() и другие агрегаты сохраняют условия исходного запроса, но игнорируют orderBy, limit, offset — это удобно для пагинации.

Пакетная выборка#

Миллион строк через all() не влезет в память. batch()/each() читают курсором порциями:

PHP
$query = (new Query())->from('user')->orderBy('id');

foreach ($query->batch() as $users) {        // по 100 строк за итерацию
    // $users — массив из ≤100 строк
}

foreach ($query->each(500) as $user) {       // по одной строке, порция 500
    // $user — одна строка
}

// с indexBy ключи внутри порции сохраняются
foreach ($query->indexBy('username')->batch() as $users) { }

Выражения и подзапросы#

PHP
use yii\db\Expression;

$query->select(['id', new Expression('NOW() AS now')]);
$query->where(new Expression('YEAR(created_at) = :y', [':y' => 2024]));
$query->where(['>', 'expires', new Expression('NOW()')]);

$sub = (new Query())->select('user_id')->from('post')->where(['status' => 1]);
// WHERE id IN (SELECT user_id FROM post WHERE status=1)
$q   = (new Query())->from('user')->where(['id' => $sub]);

Expression вставляется в SQL как есть — никогда не подставляйте в него пользовательские данные без параметров.

Query как строитель для DAO#

PHP
$command = (new Query())->from('user')->where(['status' => 1])->createCommand();
echo $command->sql;         // SELECT * FROM `user` WHERE `status`=:qp0
$command->params;           // [':qp0' => 1]
$rows = $command->queryAll();

Так можно применить построитель, но выполнить запрос вручную — например, через другое соединение или с дополнительной обработкой.

Проверь себя5

Хеш или операторный формат с filterWhere(): значения привязываются параметрами, а пустые поля игнорируются.

id IN (4, 8, 15); null в значении даст IS NULL.

andFilterWhere() пропускает условия с пустыми значениями ('', ' ', null, []), andWhere() добавляет всегда.

foreach ($query->batch(1000) as $rows) или each(); в MySQL — с небуферизованным соединением.

Нет — агрегаты игнорируют limit, offset и orderBy, но сохраняют where, join, groupBy.

25Active Record

Модель = строка таблицы — объявление класса, find()/findOne()/findAll(), сохранение и «грязные» атрибуты, события жизненного цикла, транзакции и оптимистичная блокировка, связи hasOne/hasMany/via, жадная загрузка with()/joinWith(), link()/unlink(), собственный ActiveQuery.

Active Record связывает класс с таблицей: объект — строка, свойства — столбцы. Customer::findOne(1) читает, $customer->save() пишет, $customer->orders подгружает связанные записи. Внутри — тот же Query Builder и DAO, поэтому всё, что умеют они, доступно и здесь. Поддерживаются реляционные СУБД, а также Redis, MongoDB, Elasticsearch и Sphinx через расширения.

Объявление#

PHP
namespace app\models;

use yii\db\ActiveRecord;

class Customer extends ActiveRecord
{
    const STATUS_INACTIVE = 0;
    const STATUS_ACTIVE = 1;

    public static function tableName()
    {
        return '{{%customer}}';        // по умолчанию — имя класса в snake_case: 'customer'
    }

    // public static function getDb() { return Yii::$app->db2; }   // другое соединение
}

Столбцы становятся атрибутами автоматически — по схеме таблицы ($customer->email). Как и любая модель, AR поддерживает rules(), scenarios(), attributeLabels(), load(), массовое присваивание и валидацию.

Чтение#

PHP
// построение через ActiveQuery — все методы Query Builder
$customers = Customer::find()->where(['status' => Customer::STATUS_ACTIVE])->orderBy('id')->all();
$customer  = Customer::find()->where(['id' => 1])->one();
$count     = Customer::find()->where(['status' => 1])->count();

// сокращения — по первичному ключу или условию
$customer  = Customer::findOne(123);                        // WHERE id = 123
$customers = Customer::findAll([100, 101, 123]);            // WHERE id IN (…)
$customer  = Customer::findOne(['id' => 123, 'status' => 1]);
$customers = Customer::findAll(['status' => 1]);

// сырой SQL
$customers = Customer::findBySql('SELECT * FROM customer WHERE status=:status', [':status' => 1])->all();

Форма результата#

PHP
// массивы вместо объектов — быстрее, но без приведения типов и связей-объектов
$rows = Customer::find()->asArray()->all();
$customers = Customer::find()->indexBy('id')->all();

foreach (Customer::find()->batch(100) as $customers) { }   // пакетами, как в Query
foreach (Customer::find()->with('orders')->each() as $customer) { }   // связи тоже грузятся по пакетам

Доступ к данным#

PHP
$customer = Customer::findOne(1);
$id = $customer->id;                 // атрибут — свойство объекта
$customer->email = 'new@example.com';

$customer->attributes;               // все атрибуты массивом
$customer->getAttributes(['id', 'email']);
$customer->isNewRecord;              // true у только что созданного объекта
$customer->primaryKey;
Customer::primaryKey();              // ['id']
Customer::getTableSchema()->columns;

Значения приходят в PHP-типах согласно схеме (int, string, float, bool, null), в отличие от DAO, где всё — строки. Даты остаются строками.

Сохранение#

PHP
$customer = new Customer();
$customer->name = 'James';
$customer->email = 'james@example.com';
$customer->save();                   // INSERT (isNewRecord == true)

$customer = Customer::findOne(123);
$customer->email = 'james@newexample.com';
$customer->save();                   // UPDATE

$customer->insert(); $customer->update();   // явно; save() выбирает сам

save() сначала выполняет валидацию: при ошибках вернёт false, ничего не записав, — проверяйте результат и $model->errors. save(false) пропускает валидацию (когда данные уже проверены).

Грязные атрибуты#

В UPDATE попадают только изменённые (dirty) атрибуты; если их нет — запрос не выполняется вовсе:

PHP
$customer->dirtyAttributes;          // ['email' => 'new@…']
$customer->oldAttributes;            // значения на момент загрузки
$customer->getOldAttribute('email');
$customer->markAttributeDirty('email');   // принудительно

Сравнение строгое (===): 1 и '1' — разные значения.

Значения по умолчанию, приведение типов, счётчики#

PHP
$customer = new Customer();
$customer->loadDefaultValues();      // DEFAULT из схемы таблицы

// UPDATE post SET view_count = view_count + 1 — атомарно, без гонок
$post->updateCounters(['view_count' => 1]);

// массово, без загрузки объектов (события не срабатывают!)
Customer::updateAll(['status' => 1], 'status = 2');
Customer::updateAllCounters(['age' => 1], ['status' => 1]);
Customer::deleteAll(['status' => 0]);

updateAll()/deleteAll() не вызывают события и поведения (TimestampBehavior не обновит updated_at) — цена скорости.

Удаление#

PHP
$customer = Customer::findOne(123);
$customer->delete();                  // DELETE одной записи, с событиями

Customer::deleteAll(['status' => 0]); // массово

Жизненный цикл#

Именно в него встраиваются поведения и переопределённые методы.

new
__construct() → init() → событие EVENT_INIT
find() / findOne()
конструктор → init() → EVENT_INIT → заполнение атрибутов → afterFind() → EVENT_AFTER_FIND
save()
beforeValidate() → EVENT_BEFORE_VALIDATE → валидация → afterValidate() → EVENT_AFTER_VALIDATE → beforeSave() → EVENT_BEFORE_INSERT/UPDATE → INSERT/UPDATE → afterSave() → EVENT_AFTER_INSERT/UPDATE
delete()
beforeDelete() → EVENT_BEFORE_DELETE → DELETE → afterDelete() → EVENT_AFTER_DELETE
refresh()
перечитать из БД → afterRefresh() → EVENT_AFTER_REFRESH
PHP
public function beforeSave($insert)
{
    if (!parent::beforeSave($insert)) {
        return false;                 // отменить сохранение
    }
    if ($insert) {
        $this->token = Yii::$app->security->generateRandomString();
    }
    return true;
}

Возврат false из beforeValidate(), beforeSave(), beforeDelete() отменяет операцию. Не забывайте parent::…() — иначе не сработают события и поведения.

Транзакции#

PHP
// вручную — как в DAO
Customer::getDb()->transaction(function ($db) use ($customer, $order) {
    $customer->save();
    $order->save();
});

// декларативно: какие операции всегда обернуть в транзакцию
class Post extends ActiveRecord
{
    public function transactions()
    {
        return [
            'admin' => self::OP_INSERT,
            'api'   => self::OP_INSERT | self::OP_UPDATE | self::OP_DELETE,
            // 'default' => self::OP_ALL,
        ];
    }
}

transactions() привязан к сценариям: в сценарии api любой save()/delete() — вместе с beforeSave()/afterSave() — выполняется внутри транзакции.

Оптимистичная блокировка#

Защищает от ситуации «два пользователя редактируют одну запись»:

PHP
// 1. столбец version INT в таблице
// 2. в модели
public function optimisticLock() { return 'version'; }
// 3. в форме — скрытое поле
echo $form->field($model, 'version')->hiddenInput();
// 4. в контроллере
try {
    $model->save();
} catch (\yii\db\StaleObjectException $e) {
    // запись уже изменили — показать diff, предложить перезагрузить
}

Если версия в БД отличается от отправленной, update()/delete() выбросят StaleObjectException. Готовое решение — OptimisticLockBehavior.

Связи#

Объявление#

PHP
class Customer extends ActiveRecord
{
    public function getOrders()
    {
        return $this->hasMany(Order::class, ['customer_id' => 'id']);
        //                       связанный класс   [столбец в Order => столбец в Customer]
    }
}

class Order extends ActiveRecord
{
    public function getCustomer()
    {
        return $this->hasOne(Customer::class, ['id' => 'customer_id']);
    }

    public function getBigItems()               // связь с условием
    {
        return $this->hasMany(Item::class, ['id' => 'item_id'])
            ->viaTable('order_item', ['order_id' => 'id'])
            ->where(['>', 'subtotal', 100])
            ->orderBy('id');
    }
}

Массив соответствий читается как «столбец связанного класса ⇒ столбец текущего». Порядок — сначала чужой столбец — самая частая ошибка.

Доступ#

PHP
$customer = Customer::findOne(123);
$orders   = $customer->orders;            // ленивая загрузка: SELECT * FROM order WHERE customer_id = 123
$orders   = $customer->orders;            // второй раз — из кэша объекта, без запроса
$customer->orders = null; unset($customer->orders);   // сбросить кэш

$query = $customer->getOrders();          // объект ActiveQuery — можно достроить
$orders = $query->where(['status' => 1])->orderBy('id')->all();

$customer->orders вернёт массив объектов для hasMany и объект либо null для hasOne.

Связь через промежуточную таблицу#

PHP
public function getItems()
{
    return $this->hasMany(Item::class, ['id' => 'item_id'])
        ->viaTable('order_item', ['order_id' => 'id']);        // таблица без класса
}

public function getOrderItems()
{
    return $this->hasMany(OrderItem::class, ['order_id' => 'id']);
}
public function getItems()
{
    return $this->hasMany(Item::class, ['id' => 'item_id'])
        ->via('orderItems');                                     // через другую связь
}

Жадная загрузка — против N+1#

PHP
// плохо: 1 + N запросов
$customers = Customer::find()->limit(100)->all();
foreach ($customers as $customer) {
    $orders = $customer->orders;
}

// хорошо: 2 запроса — SELECT * FROM customer …; SELECT * FROM order WHERE customer_id IN (…)
$customers = Customer::find()->with('orders')->limit(100)->all();

// несколько связей и вложенность
Customer::find()->with('orders.items', 'country')->all();
// с настройкой запроса связи
Customer::find()->with([
    'orders' => function (\yii\db\ActiveQuery $query) {
        $query->andWhere(['status' => Order::STATUS_ACTIVE]);
    },
])->all();
// дозагрузить уже выбранным объектам
$customer->populateRelation('orders', $orders);

JOIN по связи#

with() не добавляет JOIN — фильтровать основную выборку по связанной таблице через него нельзя. Для этого joinWith():

PHP
$orders = Order::find()
    ->joinWith('customer')                       // LEFT JOIN customer ON …
    ->where(['customer.status' => 1])            // теперь можно
    ->orderBy('customer.id, order.id')
    ->all();

Order::find()->innerJoinWith('customer');
Order::find()->joinWith('customer', false);      // только JOIN, без загрузки связи (eager = false)
Order::find()->joinWith(['books' => function ($q) { $q->onCondition(['book.category_id' => 1]); }]);
Order::find()->joinWith('customer')->with('items');   // комбинировать можно

Условия связи: onCondition, псевдонимы, inverseOf#

PHP
public function getActiveOrders()
{
    return $this->hasMany(Order::class, ['customer_id' => 'id'])
        ->onCondition(['status' => 1]);   // попадёт в ON при joinWith и в WHERE при ленивой загрузке
}

Customer::find()->joinWith(['orders o'])->where(['o.status' => 1]);   // псевдоним связанной таблицы (2.0.7)
Customer::find()->joinWith(['orders' => function ($q) { $q->alias('o'); }]);

public function getOrders()
{
    return $this->hasMany(Order::class, ['customer_id' => 'id'])->inverseOf('customer');
}
$orders = $customer->orders;
$orders[0]->customer === $customer;   // true — обратная связь без запроса

Связи между разными БД#

Возможны: Customer в MySQL, Comment в MongoDB — просто не используйте joinWith.

Сохранение связей#

PHP
$customer = Customer::findOne(123);
$order = new Order(['subtotal' => 100]);
$order->link('customer', $customer);     // выставит customer_id и сохранит $order
$customer->link('orders', $order);       // то же с другой стороны

$order->link('items', $item);            // для via/viaTable — добавит строку в промежуточную таблицу
$order->unlink('items', $item, true);    // true — удалить строку промежуточной таблицы / связанную запись

Оба объекта должны быть сохранены до link(); link() сам вызывает save(false) — предварительно валидируйте.

Собственный ActiveQuery#

Общие условия выборки — в один класс:

PHP
namespace app\models;

use yii\db\ActiveQuery;

class CommentQuery extends ActiveQuery
{
    public function active($state = true)
    {
        return $this->andWhere(['active' => $state]);
    }
}

class Comment extends ActiveRecord
{
    public static function find()
    {
        return new CommentQuery(get_called_class());
    }
}

Comment::find()->active()->all();
$customer->getComments()->active()->all();   // и в связях
Customer::find()->with(['comments' => function (CommentQuery $q) { $q->active(); }])->all();

Условие «по умолчанию» для всех запросов — переопределить init() запроса или where()… но осторожно: updateAll()/deleteAll() через него не проходят.

Дополнительные поля из запроса#

PHP
class Room extends ActiveRecord
{
    public $volume;      // НЕ столбец таблицы — заполнится из псевдонима в SELECT
}

$rooms = Room::find()
    ->select(['{{room}}.*', '([[length]] * [[width]] * [[height]]) AS volume'])
    ->where(['>', 'volume', 10])        // по вычисленному столбцу можно фильтровать и сортировать
    ->orderBy('volume DESC')
    ->all();

Заполняются публичные свойства, которых нет среди столбцов таблицы. Объявлять так существующий столбец бессмысленно: его значение уйдёт в атрибуты, а одноимённое свойство останется пустым — и вычисление по нему даст ноль. Так же добавляют агрегаты по связям: ->select(['customer.*', 'ordersCount' => 'COUNT(o.id)'])->joinWith('orders o', false)->groupBy('customer.id').

Проверь себя6

Столбец customer_id таблицы Order (связанный класс) равен столбцу id текущей модели Customer.

with() делает отдельный запрос для связи (без JOIN) и не позволяет фильтровать по её столбцам; joinWith() добавляет JOIN и позволяет where по связанной таблице.

Не прошла валидация: сохранение отменено, ошибки в $model->errors.

Нет — массовые операции не создают объекты и не вызывают события и поведения.

Атрибуты, изменённые после загрузки; только они попадают в UPDATE. Если изменений нет, save() не выполняет запрос.

Защита от перезаписи чужих правок: при несовпадении версии save() бросит StaleObjectException.

26Миграции

Версионирование схемы БД — создание миграции, up/down и safeUp/safeDown, генерация по шаблонам (create_table, add_column, junction), методы Migration, команды migrate/up|down|redo|fresh|mark|history|new, несколько БД и пространства имён.

Миграция — PHP-класс с методами «применить» и «откатить», который меняет структуру или данные БД. Файлы лежат в репозитории, применяются командой ./yii migrate, а таблица migration помнит, что уже выполнено. Так схема эволюционирует синхронно с кодом на всех машинах команды и серверах.

Создать миграцию#

Консоль
./yii migrate/create create_news_table
# → migrations/m150101_185401_create_news_table.php

Имя файла m<YYMMDD_HHMMSS>_<имя> — порядок применения определяется временной меткой.

migrations/m150101_185401_create_news_table.phpPHP
use yii\db\Migration;

class m150101_185401_create_news_table extends Migration
{
    public function up()
    {
        $this->createTable('news', [
            'id' => $this->primaryKey(),
            'title' => $this->string()->notNull(),
            'content' => $this->text(),
            'created_at' => $this->integer()->notNull()->defaultValue(0),
        ]);
        $this->createIndex('idx-news-title', 'news', 'title');
    }

    public function down()
    {
        $this->dropTable('news');
        // return false;   // если откат невозможен
    }
}

up/down или safeUp/safeDown#

safeUp()/safeDown() оборачивают всё в транзакцию: исключение — и всё откатывается. Используйте их по умолчанию; up()/down() — только если СУБД не умеет транзакционный DDL (MySQL коммитит неявно на каждый CREATE TABLE, так что там разницы почти нет).

Строитель типов столбцов#

Вместо строк 'string NOT NULL' — переносимые методы yii\db\SchemaBuilderTrait:

PHP
'id'         => $this->primaryKey(),          // pk: INT AUTO_INCREMENT PRIMARY KEY (bigPrimaryKey())
'title'      => $this->string(255)->notNull()->unique(),
'content'    => $this->text(),
'price'      => $this->decimal(10, 2)->defaultValue(0),
'is_active'  => $this->boolean()->defaultValue(true),
'created_at' => $this->timestamp()->defaultExpression('CURRENT_TIMESTAMP'),
'status'     => $this->smallInteger()->notNull()->defaultValue(1)->comment('статус'),
'user_id'    => $this->integer()->notNull()->after('id'),   // after()/first() — только MySQL
// также: char, float, double, money, date, time, dateTime, binary, tinyInteger, json (2.0.14)

Строковые типы тоже работают: 'string NOT NULL', 'pk', 'bigpk', 'text' — конвертируются под СУБД.

Методы Migration#

createTable($table, $columns, $options) / dropTable / renameTable / truncateTable
таблицы; $options — например 'ENGINE=InnoDB' для MySQL
addColumn / dropColumn / renameColumn / alterColumn
столбцы
addPrimaryKey / dropPrimaryKey
первичные ключи
addForeignKey($name, $table, $columns, $refTable, $refColumns, $delete, $update) / dropForeignKey
внешние ключи ('CASCADE', 'SET NULL', 'RESTRICT')
createIndex($name, $table, $columns, $unique = false) / dropIndex
индексы
addCommentOnColumn / addCommentOnTable / dropCommentFrom…
комментарии
insert / batchInsert / update / delete / upsert
данные
execute($sql, $params)
произвольный SQL

Все они выводят время выполнения и работают через $this->db. Обычные Yii::$app->db->createCommand() тоже доступны, но прогресса не покажут.

PHP
$this->createTable('{{%post}}', [...]);        // {{%…}} — с префиксом таблиц
$this->addForeignKey('fk-post-author_id', '{{%post}}', 'author_id', '{{%user}}', 'id', 'CASCADE');
$this->insert('{{%category}}', ['name' => 'Общее']);

Генерация по имени#

Команда migrate/create понимает шаблоны имён и заполняет тело за вас:

Консоль
./yii migrate/create create_post_table                       # createTable + dropTable
./yii migrate/create create_post_table --fields="title:string(12):notNull:unique,body:text"
./yii migrate/create create_post_table \
    --fields="author_id:integer:notNull:foreignKey(user),category_id:integer:defaultValue(1):foreignKey"
./yii migrate/create drop_post_table --fields="title:string(12):notNull"    # dropTable + createTable в down
./yii migrate/create add_position_column_to_post_table --fields="position:integer"
./yii migrate/create drop_position_column_from_post_table --fields="position:integer"
./yii migrate/create create_junction_table_for_post_and_tag_tables   # post_tag(post_id, tag_id) + PK + FK

Поле id:primaryKey добавляется автоматически, если не задано. foreignKey(user) создаст индекс и внешний ключ на user.id. Формат --fields: имя:тип(args):модификатор(args):….

Применить и откатить#

Консоль
./yii migrate                    # применить все новые (спросит подтверждение)
./yii migrate 3                  # только 3 следующие
# до указанной (или m150101_185401_create_news_table, или timestamp, или 'yyyy-mm-dd hh:mm:ss')
./yii migrate/to 150101_185401
./yii migrate/down               # откатить последнюю
./yii migrate/down 3             # три последние; migrate/down all — все
./yii migrate/redo               # откатить и снова применить последнюю (redo 3, redo all)
./yii migrate/fresh              # ⚠ удалить все таблицы и применить всё с нуля — только dev
./yii migrate/history            # что применено (history 5 / history all)
./yii migrate/new                # что ещё не применено
./yii migrate/mark 150101_185401 # пометить как применённую без выполнения (mark m000000_000000_base — сбросить всё)
./yii migrate --interactive=0    # без вопросов — для CI

Настройка#

Консоль
./yii migrate --migrationPath=@app/modules/forum/migrations
./yii migrate --migrationTable=my_migrations
./yii migrate --db=db2                                   # другое соединение
./yii migrate --migrationPath=@yii/rbac/migrations        # миграции расширений (RBAC, i18n, queue…)

Постоянно — в консольной конфигурации:

config/console.phpPHP
'controllerMap' => [
    'migrate' => [
        'class' => 'yii\console\controllers\MigrateController',
        'migrationPath' => null,                 // отключить старый механизм
        'migrationNamespaces' => [               // миграции с пространствами имён (2.0.10)
            'app\migrations',
            'some\extension\migrations',
        ],
        // 'migrationPath' => ['@app/migrations', '@app/modules/forum/migrations'],   // или несколько путей
    ],
],

Именованная миграция создаётся ./yii migrate/create app\\migrations\\CreateUserTable → класс M150101185401CreateUserTable в папке псевдонима @app/migrations.

Несколько БД#

Либо своя команда с --db, либо переопределить getDb() в миграции. Разложить по папкам (migrations/db1, migrations/db2) и запускать с разными --migrationPath.

Быстрый рецепт#

  1. ./yii migrate/create create_post_table --fields="title:string:notNull,body:text,author_id:integer:notNull:foreignKey(user)"
  2. Проверить сгенерированный файл, поправить типы, добавить индексы.
  3. ./yii migrate локально; закоммитить файл миграции.
  4. На сервере после деплоя: ./yii migrate --interactive=0.
  5. Ошибка после деплоя — ./yii migrate/down вернёт схему назад.
Проверь себя5

safeUp() выполняется внутри транзакции — при исключении всё откатывается (если СУБД поддерживает транзакционный DDL).

Ничего: применённые миграции помнит таблица migration, выполняются только новые.

./yii migrate/create create_post_table --fields="author_id:integer:notNull:foreignKey(user)".

./yii migrate --migrationPath=@yii/rbac/migrations или добавить путь/пространство имён в конфигурацию controllerMap.

Когда откат невозможен (например, данные уничтожены); тогда migrate/down остановится с сообщением.

📝 Формы и валидация

27Формы и ввод

ActiveForm и ActiveField — поля, списки, шаблоны и подсказки; загрузка файлов через UploadedFile; табличный ввод (loadMultiple/validateMultiple) и несколько моделей в одной форме; JavaScript-API формы (валидация, события, динамические поля).

Форма в Yii — это модель (правила и подписи), виджет ActiveForm (HTML + клиентская валидация) и контроллер (load() → validate() → действие). Всё уже сцеплено: $form->field($model, 'email') сам рисует label, input с именем LoginForm[email], подсказку, ошибку и подключает JS-правила из rules().

Классическая тройка#

models/EntryForm.phpPHP
class EntryForm extends Model
{
    public $name;
    public $email;

    public function rules()
    {
        return [
            [['name', 'email'], 'required'],
            ['email', 'email'],
        ];
    }
}
controllers/SiteController.phpPHP
public function actionEntry()
{
    $model = new EntryForm();

    if ($model->load(Yii::$app->request->post()) && $model->validate()) {
        // данные проверены — сохранить, отправить письмо…
        return $this->redirect(['entry-confirm']);   // PRG: редирект после POST
    }
    return $this->render('entry', ['model' => $model]);
}
views/site/entry.phpPHP
<?php $form = ActiveForm::begin(['id' => 'entry-form']); ?>
    <?= $form->field($model, 'name') ?>
    <?= $form->field($model, 'email')->input('email') ?>
    <div class="form-group">
        <?= Html::submitButton('Отправить', ['class' => 'btn btn-primary']) ?>
    </div>
<?php ActiveForm::end(); ?>

load() берёт из POST массив по имени формы (EntryForm) и присваивает только безопасные (перечисленные в rules()) атрибуты. Форма отправляет CSRF-токен автоматически.

ActiveField — виды полей#

PHP
$form->field($model, 'password')->passwordInput();
$form->field($model, 'bio')->textarea(['rows' => 6]);
$form->field($model, 'age')->input('number', ['min' => 18]);
$form->field($model, 'agree')->checkbox();
$form->field($model, 'id')->hiddenInput()->label(false);
$form->field($model, 'file')->fileInput();

$form->field($model, 'product_id')->dropDownList(
    ArrayHelper::map(Product::find()->all(), 'id', 'name'),        // [id => name]
    ['prompt' => 'Выберите товар']
);
$form->field($model, 'tags')->listBox($items, ['multiple' => true]);
$form->field($model, 'gender')->radioList(['m' => 'Муж.', 'f' => 'Жен.']);
$form->field($model, 'roles')->checkboxList($roles);

$form->field($model, 'date')->widget(DatePicker::class, ['dateFormat' => 'yyyy-MM-dd']);   // любой InputWidget

// оформление
$form->field($model, 'name')->label('Имя пользователя')->hint('Как в паспорте');
$form->field($model, 'name', ['template' => "{label}\n<div class=\"col-9\">{input}</div>\n{error}"]);
$form->field($model, 'name', ['inputOptions' => ['placeholder' => 'Иван']]);

Настройки формы целиком: ActiveForm::begin(['fieldConfig' => ['template' => '…'], 'options' => ['enctype' => 'multipart/form-data'], 'enableClientValidation' => true, 'enableAjaxValidation' => false, 'validateOnBlur' => true]).

Списки: где взять данные#

PHP
use yii\helpers\ArrayHelper;

$items = ArrayHelper::map(Country::find()->orderBy('name')->all(), 'code', 'name');
// [ 'RU' => 'Россия', 'US' => 'США' ]
$items = ArrayHelper::map($rows, 'id', 'name', 'group');   // группы → <optgroup>

Pjax и формы#

PHP
<?php Pjax::begin(['id' => 'pjax-form']) ?>
<?php $form = ActiveForm::begin(['options' => ['data-pjax' => true]]) ?>
    …
<?php ActiveForm::end() ?>
<?php Pjax::end() ?>

Атрибут data-pjax на форме — и отправка пройдёт без перезагрузки страницы, обновив только контейнер. Внутри Pjax не дублируйте ID формы; ссылки внутри тоже станут pjax-запросами.

Загрузка файлов#

models/UploadForm.phpPHP
use yii\web\UploadedFile;

class UploadForm extends Model
{
    /** @var UploadedFile */
    public $imageFile;

    public function rules()
    {
        return [
            [['imageFile'], 'file', 'skipOnEmpty' => false, 'extensions' => 'png, jpg', 'maxSize' => 1024 * 1024],
            // 'image' — валидатор с проверкой размеров: 'minWidth', 'maxHeight'…
        ];
    }

    public function upload()
    {
        if ($this->validate()) {
            $file = $this->imageFile;
            $file->saveAs('@webroot/uploads/' . $file->baseName . '.' . $file->extension);
            return true;
        }
        return false;
    }
}
controllers/SiteController.phpPHP
public function actionUpload()
{
    $model = new UploadForm();
    if (Yii::$app->request->isPost) {
        $model->imageFile = UploadedFile::getInstance($model, 'imageFile');
        if ($model->upload()) {
            return;   // файл сохранён
        }
    }
    return $this->render('upload', ['model' => $model]);
}
views/site/upload.phpPHP
<?php $form = ActiveForm::begin() ?>
    <?= $form->field($model, 'imageFile')->fileInput() ?>  <?php // enctype проставится сам ?>
    <button>Загрузить</button>
<?php ActiveForm::end() ?>

Несколько файлов#

PHP
public $imageFiles;   // массив UploadedFile

[['imageFiles'], 'file', 'skipOnEmpty' => false, 'extensions' => 'png, jpg', 'maxFiles' => 4],

// контроллер
$model->imageFiles = UploadedFile::getInstances($model, 'imageFiles');
foreach ($model->imageFiles as $file) {
    $file->saveAs('uploads/' . $file->baseName . '.' . $file->extension);
}

// представление: имя атрибута с [] и multiple
<?= $form->field($model, 'imageFiles[]')->fileInput(['multiple' => true, 'accept' => 'image/*']) ?>

maxFiles => 0 снимает ограничение на количество.

Табличный ввод — много одинаковых моделей#

Редактируем сразу несколько записей (например, все настройки):

PHP
public function actionBatchUpdate()
{
    $settings = Setting::find()->indexBy('id')->all();

    if (Model::loadMultiple($settings, Yii::$app->request->post()) && Model::validateMultiple($settings)) {
        foreach ($settings as $setting) {
            $setting->save(false);
        }
        return $this->redirect('index');
    }
    return $this->render('batch-update', ['settings' => $settings]);
}
PHP
<?php foreach ($settings as $index => $setting): ?>
    <?= $form->field($setting, "[$index]value")->label($setting->name) ?>
<?php endforeach; ?>

Имя поля станет Setting[1][value], loadMultiple() разложит их по объектам. Для создания N новых записей — заранее создать N объектов new Setting() (или расширить массив под количество пришедших строк) и то же самое.

Несколько разных моделей в одной форме#

PHP
$user = User::findOne($id);
$profile = $user->profile;

if ($user->load(Yii::$app->request->post()) && $profile->load(Yii::$app->request->post())) {
    $isValid = $user->validate();
    $isValid = $profile->validate() && $isValid;       // валидировать обе, даже если первая упала
    if ($isValid) {
        $user->save(false);
        $profile->save(false);
        return $this->redirect(['user/view', 'id' => $id]);
    }
}
return $this->render('update', ['user' => $user, 'profile' => $profile]);
PHP
<?= $form->field($user, 'username') ?>
<?= $form->field($profile, 'website') ?>

Разные классы — разные имена форм (User[username], Profile[website]), конфликтов нет. Если классы одинаковые, задайте formName() или используйте индексы, как в табличном вводе.

JavaScript-API ActiveForm#

Виджет подключает yii.activeForm.js и передаёт ему правила из clientValidateAttribute() валидаторов.

Настройка валидации#

enableClientValidation
JS-проверка по правилам (по умолчанию true)
enableAjaxValidation
проверка запросом на сервер (для unique, exist и других серверных правил)
validateOnBlur, validateOnChange, validateOnType (+validationDelay), validateOnSubmit
когда проверять
validationUrl
куда слать AJAX-валидацию (по умолчанию — action формы)
errorCssClass, successCssClass, validatingCssClass, errorSummaryCssClass
классы состояния

Обработчик AJAX-валидации на сервере:

PHP
if (Yii::$app->request->isAjax && $model->load(Yii::$app->request->post())) {
    Yii::$app->response->format = Response::FORMAT_JSON;
    return ActiveForm::validate($model);          // {"loginform-email": ["…"]}
}

События#

JavaScript
$('#contact-form').on('beforeValidate', function (event, messages, deferreds) { });
$('#contact-form').on('afterValidate', function (event, messages, errorAttributes) { });
$('#contact-form').on('beforeValidateAttribute', function (event, attribute, messages, deferreds) { });
$('#contact-form').on('afterValidateAttribute', function (event, attribute, messages) { });
$('#contact-form').on('beforeSubmit', function () {
    // отправить через AJAX и не отправлять форму обычным способом
    $.post($(this).attr('action'), $(this).serialize()).done(function () { });
    return false;
});
$('#contact-form').on('ajaxBeforeSend', function (event, jqXHR, settings) { });
$('#contact-form').on('ajaxComplete', function (event, jqXHR, textStatus) { });
$('#contact-form').on('afterInit', function () { });

deferreds — массив, куда можно положить $.Deferred() для асинхронных проверок; messages — объект {id: [ошибки]}, туда можно дописать свою.

Динамические поля#

JavaScript
$('#contact-form').yiiActiveForm('add', {
    id: 'address',
    name: 'address',
    container: '.field-address',
    input: '#address',
    error: '.help-block',
    validate: function (attribute, value, messages, deferred, $form) {
        yii.validation.required(value, messages, { message: 'Заполните адрес' });
    }
});
$('#contact-form').yiiActiveForm('remove', 'address');
$('#contact-form').yiiActiveForm('validate');               // проверить всю форму
$('#contact-form').yiiActiveForm('validate', true);         // только изменённые поля
$('#contact-form').yiiActiveForm('validateAttribute', 'contactform-email');
$('#contact-form').yiiActiveForm('updateAttribute', 'contactform-email', ['Занят']);   // показать ошибку
$('#contact-form').yiiActiveForm('updateMessages', { 'contactform-email': ['Занят'] }, true);
$('#contact-form').yiiActiveForm('resetForm');
$('#contact-form').yiiActiveForm('find', 'contactform-email');   // настройки атрибута

Правила и опции всех полей хранятся в $('#form').yiiActiveForm('data').

Проверь себя5

Атрибут не «безопасный»: он не упомянут в rules() (или в правиле safe) для текущего сценария, и массовое присваивание его пропустило.

UploadedFile::getInstance($model, 'attr') в контроллере, затем saveAs(). enctype="multipart/form-data" у формы тоже обязателен, но ActiveForm добавляет его сам.

Включить enableAjaxValidation и в действии вернуть ActiveForm::validate($model) в формате JSON для AJAX-запроса.

$form->field($model, "[$index]attr") — получится Model[index][attr].

Подписаться на событие beforeSubmit, отправить $.post и вернуть false.

28Валидация

Правила rules(), сценарии и on/except, пустые значения и skipOnEmpty/skipOnError, условная валидация when/whenClient, фильтрация данных, ad hoc-валидация через DynamicModel, свои валидаторы (inline и класс), клиентская, отложенная и AJAX-валидация.

Данные извне не проверены, пока не прошли validate(). Правила описываются декларативно в rules(), ошибки собираются в модели, а те же правила автоматически превращаются в клиентскую JavaScript-проверку. Это единый механизм для форм, REST-запросов и консольных команд.

rules()#

PHP
public function rules()
{
    return [
        // [атрибуты, валидатор, опции...]
        [['username', 'email', 'password'], 'required'],
        ['email', 'email'],
        ['username', 'string', 'min' => 2, 'max' => 24],
        ['username', 'unique', 'targetClass' => User::class],
        ['age', 'integer', 'min' => 18, 'message' => 'Только для взрослых'],
        ['status', 'in', 'range' => [self::STATUS_ACTIVE, self::STATUS_DELETED]],
        ['status', 'default', 'value' => self::STATUS_ACTIVE],
        ['website', 'url', 'defaultScheme' => 'https'],
        ['password_repeat', 'compare', 'compareAttribute' => 'password'],
        [['first_name', 'last_name'], 'trim'],
        ['title', 'filter', 'filter' => 'strip_tags'],
        ['title', 'validateTitle'],                       // inline-валидатор — метод модели
        ['country', \app\components\validators\CountryValidator::class],   // свой класс
        ['tags', 'safe'],                                 // без проверки, но разрешить load()
        ['token', 'required', 'on' => 'register'],        // только в сценарии
        ['email', 'unique', 'except' => 'update'],        // во всех, кроме
    ];
}

Валидатор указывается коротким именем (встроенные валидаторы), именем класса, объектом или именем метода модели. Правила применяются по порядку, атрибут проверяется всеми правилами, где он упомянут.

Запуск и ошибки#

PHP
$model = new ContactForm();
$model->load(Yii::$app->request->post());

if ($model->validate()) {                 // все атрибуты активного сценария
    // ok
} else {
    $model->errors;                       // ['email' => ['…', '…'], 'name' => ['…']]
    $model->getFirstErrors();             // ['email' => '…']
    $model->getFirstError('email');
    $model->getErrorSummary(true);        // плоский список
    $model->hasErrors('email');
}

$model->validate(['email', 'name']);      // только эти атрибуты
$model->addError('email', 'Такой уже есть');   // ошибка вручную
$model->clearErrors();

ActiveRecord::save() вызывает validate() сам. В сообщениях доступны {attribute} (подпись из attributeLabels()) и {value}, у конкретных валидаторов — свои плейсхолдеры ({min}, {max}…).

Активные правила и атрибуты#

PHP
$model->scenario = 'register';
$model->activeAttributes();     // атрибуты, участвующие в сценарии
$model->getActiveValidators();  // валидаторы сценария
$model->getActiveValidators('email');
$model->validators;             // ArrayObject всех валидаторов — можно добавить объект на лету

Правило активно, если сценарий модели попадает в on (или on не задан) и не попадает в except. Атрибуты активных правил становятся безопасными для load() — кроме тех, что помечены восклицательным знаком в scenarios().

Пустые значения#

По умолчанию валидатор пропускает пустой атрибут (null, '', []) — проверяет только required. Меняется опцией:

PHP
['email', 'email', 'skipOnEmpty' => false],
['tags', 'each', 'rule' => ['string'], 'isEmpty' => function ($value) { return $value === [] || $value === null; }],

По умолчанию Validator::isEmpty() считает пустыми null, '' и []. Ошибка при первом правиле атрибута приводит к пропуску остальных — skipOnError => false отключает это.

Условная валидация#

PHP
['state', 'required', 'when' => function ($model) {
    return $model->country == 'USA';
}, 'whenClient' => "function (attribute, value) {
    return $('#country').val() == 'USA';
}"],

when — серверное условие; whenClient — то же на JS, чтобы клиентская валидация не спорила с серверной.

Фильтрация — не проверка, а преобразование#

PHP
[['username', 'email'], 'trim'],
['username', 'filter', 'filter' => 'strtolower'],
['title', 'filter', 'filter' => function ($value) { return mb_substr($value, 0, 100); }],
['status', 'default', 'value' => 1],       // если пусто
['is_active', 'boolean'],                  // не преобразует! только проверяет

trim, filter, default меняют значение атрибута. Ставьте их перед проверяющими правилами.

Ad hoc-валидация#

Проверить значения без класса модели — DynamicModel:

PHP
public function actionSearch($name, $email)
{
    $model = DynamicModel::validateData(compact('name', 'email'), [
        [['name', 'email'], 'string', 'max' => 128],
        ['email', 'email'],
    ]);

    if ($model->hasErrors()) {
        // …
    }
}

// или одно значение
$validator = new EmailValidator();
if ($validator->validate($email, $error)) { } else { echo $error; }

DynamicModel можно создать и вручную: new DynamicModel(compact('name', 'email')) → addRule('email', 'email') → validate().

Свои валидаторы#

Inline — метод модели#

PHP
public function rules()
{
    return [
        ['country', 'validateCountry'],
        ['token', function ($attribute, $params, $validator) {
            if ($this->$attribute !== 'a') {
                $this->addError($attribute, 'Неверный токен');
            }
        }],
    ];
}

public function validateCountry($attribute, $params, $validator)
{
    if (!in_array($this->$attribute, ['USA', 'Indonesia'])) {
        $validator->addError($this, $attribute, '{attribute} должна быть "USA" или "Indonesia".');
    }
}

Inline-валидаторы, как и все, пропускают пустые значения, если не задан skipOnEmpty => false. С 2.0.11 третьим аргументом приходит сам InlineValidator, поэтому удобнее $validator->addError($this, $attribute, $message) — работает с плейсхолдерами. Клиентская часть по умолчанию не генерируется, но её можно задать: ['country', 'validateCountry', 'clientValidate' => 'clientValidateCountry'], где метод возвращает JS-код.

Отдельный класс#

PHP
namespace app\components;

use yii\validators\Validator;

class CountryValidator extends Validator
{
    public function validateAttribute($model, $attribute)
    {
        if (!in_array($model->$attribute, ['USA', 'Indonesia'])) {
            $this->addError($model, $attribute, '{attribute} должна быть "USA" или "Indonesia".');
        }
    }

    // чтобы валидатор работал и вне модели ($validator->validate($value, $error))
    protected function validateValue($value)
    {
        return in_array($value, ['USA', 'Indonesia']) ? null : ['{attribute} должна быть "USA" или "Indonesia".', []];
    }
}

Достаточно переопределить validateValue() — validateAttribute() по умолчанию вызывает его. Если у валидатора есть опции, объявите их публичными свойствами и задайте умолчания в init().

Валидатор с несколькими атрибутами#

PHP
class MigrationRule extends Validator
{
    public function validateAttribute($model, $attribute)
    {
        if ($model->$attribute == $model->otherAttribute) { … }
    }
}
// применить один раз, а не для каждого атрибута:
[['from', 'to'], MigrationRule::class],   // validateAttribute вызовется дважды
['from', MigrationRule::class],           // один раз — ошибки добавить обоим вручную

Клиентская валидация#

Встроенные валидаторы (кроме unique, exist, file частично и inline) генерируют JS через clientValidateAttribute(). Свой:

PHP
public function clientValidateAttribute($model, $attribute, $view)
{
    $statuses = json_encode(Status::find()->select('id')->column());
    $message = json_encode($this->message);
    return <<<JS
if ($.inArray(value, $statuses) === -1) {
    messages.push($message);
}
JS;
}

В JS доступны attribute, value, messages, deferred, $form. Отключить: enableClientValidation => false у формы или 'enableClientValidation' => false в правиле.

Отложенная (deferred) валидация#

Для асинхронных проверок на клиенте (например, размер изображения):

JavaScript
var def = $.Deferred();
var img = new Image();
img.onload = function () {
    if (this.width > 150) messages.push('Слишком широкое');
    def.resolve();
};
img.src = URL.createObjectURL(file);
deferred.push(def);

Форма ждёт все deferred, прежде чем показать результат.

AJAX-валидация#

Когда клиентской логики недостаточно (unique, сложные проверки):

PHP
// форма
$form = ActiveForm::begin(['enableAjaxValidation' => true]);
$form->field($model, 'username', ['enableAjaxValidation' => true]);   // или для одного поля

// действие
if (Yii::$app->request->isAjax && $model->load(Yii::$app->request->post())) {
    Yii::$app->response->format = Response::FORMAT_JSON;
    return ActiveForm::validate($model);
}

ActiveForm::validate($model, ['attr1', 'attr2']) вернёт ошибки в формате, который ждёт JS формы: второй аргумент — массив имён атрибутов. Несколько моделей перечисляют аргументами — ActiveForm::validate($user, $profile), а для табличного ввода есть ActiveForm::validateMultiple($models).

Порядок выполнения на практике#

  1. load() — массовое присваивание безопасных атрибутов сценария.
  2. validate() → beforeValidate(); false — прерывание.
  3. Для каждого правила по порядку: пропуск пустых (skipOnEmpty), пропуск с ошибками (skipOnError), условие when, затем проверка или фильтрация.
  4. afterValidate(); результат — !hasErrors().
Проверь себя5

По умолчанию skipOnEmpty = true: из проверяющих валидаторов пустые значения смотрят только required и captcha. Задайте 'skipOnEmpty' => false, если нужно.

DynamicModel::validateData($data, $rules) или new DynamicModel() с addRule().

Опцией when (и whenClient для JS): 'when' => function ($model) { return $model->country == 'USA'; }.

Реализовать validateValue($value), возвращающий null либо [сообщение, параметры].

JS-код для него сгенерировать неоткуда. Задайте clientValidate в правиле (метод вернёт JS-код), вынесите проверку в класс-валидатор или включите AJAX-валидацию.

29Встроенные валидаторы

Справочник валидаторов ядра — required, string, integer/number, email, url, ip, date, compare, in, match, boolean, default, filter, trim, each, exist, unique, file, image, captcha, safe — с ключевыми опциями и типичными примерами.

Каждый валидатор — класс в yii\validators, но в rules() его зовут коротким именем. Ниже — все встроенные, сгруппированные по назначению, с опциями, которые реально нужны. Общие для всех: message, on, except, skipOnEmpty, skipOnError, when, whenClient, enableClientValidation.

Обязательность и умолчания#

required
не пусто. requiredValue (ожидать конкретное значение), strict (сравнивать строго). Один из двух проверяющих валидаторов (второй — captcha), который срабатывает и на пустом значении; у default, filter и trim skipOnEmpty тоже выключен, но они не проверяют, а меняют значение
default
подставить value (значение или замыкание), если атрибут пуст. Не проверяет, а заполняет
safe
«ничего не проверять, но считать атрибут безопасным» для массового присваивания
PHP
[['username', 'password'], 'required'],
['accept', 'required', 'requiredValue' => 1, 'message' => 'Примите условия'],
['age', 'default', 'value' => null],
['country', 'default', 'value' => 'USA'],
['from', 'default', 'value' => function ($model) { return date('Y-m-d'); }],
['notes', 'safe'],

Строки и форматы#

string
строка длиной min..max или ровно length; encoding (по умолчанию charset приложения)
email
адрес; allowName (разрешить Иван <ivan@site.ru>), checkDNS (проверить MX-запись), enableIDN
url
URL; validSchemes (['http', 'https']), defaultScheme (дописать, если схемы нет), enableIDN
ip
IPv4/IPv6; ipv4, ipv6, subnet (true — обязательно с маской, null — можно с маской), normalize, ranges (белый/чёрный список: ['10.0.1.0/24', '!10.0.0.0/8', 'any']), negation (разрешить !)
match
регулярное выражение pattern; not — инвертировать
trim
обрезать пробелы (фильтр); chars — какие символы
filter
применить callback filter (строка, замыкание, [класс, метод]); skipOnArray — не применять к массивам
PHP
['username', 'string', 'length' => [4, 24]],
['email', 'email', 'checkDNS' => true],
['website', 'url', 'defaultScheme' => 'https'],
['ip', 'ip', 'ipv6' => false, 'ranges' => ['10.0.0.0/8', '!any']],
['username', 'match', 'pattern' => '/^[a-z]\w*$/i'],
['username', 'match', 'pattern' => '/^admin$/', 'not' => true, 'message' => 'Имя занято'],
[['title', 'body'], 'trim'],
['username', 'filter', 'filter' => 'strtolower'],
['phone', 'filter', 'filter' => function ($value) { return preg_replace('/\D/', '', $value); }],

ip при subnet => null и normalize => true приведёт 192.168.1.1 к 192.168.1.1/32; при expandIPv6 — развернёт сокращённую запись.

Числа и логика#

integer
целое; min, max, tooSmall, tooBig
number (double)
любое число; те же min/max; integerPattern, numberPattern
boolean
trueValue/falseValue (по умолчанию 1/0), strict. Не преобразует значение
compare
сравнить с другим атрибутом compareAttribute или значением compareValue; operator (==, ===, !=, !==, >, >=, <, <=); type — как сравнивать: string (по умолчанию: оба значения приводятся к строке и сравниваются операторами PHP) или number (приводятся к float)
in
значение из range; strict, not, allowArray (проверить каждый элемент массива)
PHP
['age', 'integer', 'min' => 18],
['price', 'number', 'min' => 0, 'max' => 1e6],
['salary', 'double'],
['remember_me', 'boolean'],
['is_active', 'boolean', 'trueValue' => true, 'falseValue' => false, 'strict' => true],
['password_repeat', 'compare', 'compareAttribute' => 'password'],
['age', 'compare', 'compareValue' => 30, 'operator' => '>=', 'type' => 'number'],
['level', 'in', 'range' => [1, 2, 3]],
['tags', 'in', 'range' => ['php', 'yii'], 'allowArray' => true],

Даты#

date
дата в формате format (ICU: yyyy-MM-dd, или php:Y-m-d); timestampAttribute — куда записать UNIX-время; timestampAttributeFormat — или в каком формате записать; min/max (+tooSmall/tooBig); timeZone, locale
datetime, time
тот же валидатор с type => 'datetime' / 'time' (короткие имена — с 2.0.10)
PHP
[['from_date', 'to_date'], 'date'],
['birthday', 'date', 'format' => 'php:d.m.Y', 'timestampAttribute' => 'birthday_ts'],
['created', 'datetime', 'format' => 'php:Y-m-d H:i:s',
    'timestampAttribute' => 'created', 'timestampAttributeFormat' => 'php:Y-m-d H:i:s'],
['start', 'date', 'min' => date('Y-m-d'), 'tooSmall' => 'Дата уже прошла'],

Без format берётся dateFormat компонента formatter. С timestampAttribute проверенное значение преобразуется в UNIX-timestamp и записывается в атрибут (можно в тот же самый) — удобно для сохранения в INT.

Массивы и вложенность#

each
применить rule к каждому элементу массива; allowMessageFromRule (сообщение от вложенного правила), stopOnFirstError
PHP
['categoryIDs', 'each', 'rule' => ['integer']],
['emails', 'each', 'rule' => ['email'], 'allowMessageFromRule' => false, 'message' => 'Один из адресов неверен'],

Проверяет только один уровень; для сложных структур — свой валидатор. Не работает с unique/exist через targetAttribute.

Проверки по базе данных#

exist
значение существует в таблице: targetClass (по умолчанию — класс модели), targetAttribute (столбец или карта ['a1' => 'b1']), filter (доп. условие), allowArray, targetRelation (по связи AR, 2.0.14)
unique
значение уникально в таблице; те же опции, comboNotUnique (сообщение для составного ключа). Для AR исключает саму запись при обновлении
PHP
['a1', 'exist'],                                                  // столбец a1 в таблице модели
['category_id', 'exist', 'targetClass' => Category::class, 'targetAttribute' => 'id'],
// пара (a1, a2) должна существовать; ошибка — только у a1
['a1', 'exist', 'targetAttribute' => ['a1', 'a2']],
// значение a2 ищется в столбце a1: [атрибут модели => столбец таблицы]
['a1', 'exist', 'targetAttribute' => ['a2' => 'a1']],
['a1', 'exist', 'filter' => ['status' => 1]],
['user_id', 'exist', 'targetRelation' => 'user'],

['username', 'unique'],
['email', 'unique', 'targetClass' => User::class, 'filter' => ['status' => User::STATUS_ACTIVE]],
[['slug', 'lang'], 'unique', 'targetAttribute' => ['slug', 'lang']],   // комбинация уникальна

Оба не имеют клиентской реализации — используйте AJAX-валидацию.

Файлы и изображения#

file
UploadedFile: extensions, mimeTypes (image/*, text/plain), minSize, maxSize, maxFiles (0 — без ограничения), checkExtensionByMimeType (сверять расширение с реальным MIME, по умолчанию true), wrongExtension, tooBig, tooMany…
image
file + проверка изображения: minWidth, maxWidth, minHeight, maxHeight, notImage, underWidth, overWidth…
PHP
['file', 'file', 'extensions' => ['pdf', 'docx'], 'maxSize' => 5 * 1024 * 1024, 'skipOnEmpty' => false],
['avatar', 'image', 'extensions' => 'png, jpg', 'minWidth' => 100, 'maxWidth' => 1000, 'minHeight' => 100],
['docs', 'file', 'maxFiles' => 10],

Помните про upload_max_filesize/post_max_size в php.ini и enctype="multipart/form-data" — подробнее в разделе Формы.

CAPTCHA#

captcha
сверяет ввод с виджетом yii\captcha\Captcha; captchaAction (по умолчанию site/captcha), caseSensitive. Как и required, работает с пустым значением — пустое поле сразу даёт ошибку
PHP
// модель
['verifyCode', 'captcha'],
// контроллер: действие captcha в actions()
'captcha' => ['class' => 'yii\captcha\CaptchaAction', 'fixedVerifyCode' => YII_ENV_TEST ? 'testme' : null],
// форма
<?= $form->field($model, 'verifyCode')->widget(Captcha::class) ?>

Требует расширения GD или ImageMagick с поддержкой FreeType.

Сводка по ключевым опциям#

Валидатор Самое важное
requiredrequiredValue, strict
stringmin, max, length
integer, numbermin, max
email, urlcheckDNS / defaultScheme, enableIDN
comparecompareAttribute, operator, type
inrange, not, allowArray
dateformat, timestampAttribute, min, max
eachrule
exist, uniquetargetClass, targetAttribute, filter
file, imageextensions, maxSize, maxFiles, minWidth…
matchpattern, not
filter, trim, defaultfilter / chars / value — меняют значение
Проверь себя5

required и captcha; остальные проверяющие пропускают пустые атрибуты, пока не указан 'skipOnEmpty' => false. У default, filter и trim он тоже выключен, но они не проверяют, а меняют значение.

Записывает в указанный атрибут UNIX-время (или строку в timestampAttributeFormat), полученное из проверенной даты.

Когда значения — не просто числа. Сравнение по умолчанию строковое, и '2024-1-5' > '2024-01-10' оказывается истиной. Для чисел задайте 'type' => 'number', для дат сравнивайте UNIX-время из timestampAttribute.

['ids', 'each', 'rule' => ['integer']].

Правилом ['attr', 'safe'].

📊 Вывод данных

30Форматирование данных

Компонент formatter — asDate/asDatetime/asRelativeTime, asInteger/asDecimal/asCurrency/asPercent, asSize/asShortSize, asBoolean/asEmail/asUrl/asHtml/asNtext; форматы ICU и php:, локаль и часовые пояса, nullDisplay; где форматы применяются автоматически (GridView, DetailView).

Yii::$app->formatter превращает «сырые» значения в человеческий вид с учётом локали: даты, числа, деньги, размеры файлов, логические флаги. Те же имена форматов используют GridView и DetailView в колонках ('created_at:datetime'), так что один компонент отвечает за весь вывод.

Как вызывать#

PHP
$formatter = Yii::$app->formatter;

echo $formatter->asDate('2014-01-01', 'long');        // January 1, 2014
echo $formatter->asPercent(0.125, 2);                 // 12.50%
echo $formatter->asEmail('cebe@example.com');         // <a href="mailto:…">…</a>
echo $formatter->asBoolean(true);                     // Yes
echo $formatter->asDate(null);                        // (not set)

// универсально — по имени формата
echo $formatter->format('2014-01-01', 'date');
echo $formatter->format(0.125, ['percent', 2]);        // формат с аргументами

null в любом методе выводится как nullDisplay (по умолчанию (not set), локализуется).

Настройка#

config/web.phpPHP
'components' => [
    'formatter' => [
        'dateFormat' => 'dd.MM.yyyy',
        'datetimeFormat' => 'dd.MM.yyyy HH:mm',
        'timeFormat' => 'HH:mm',
        'decimalSeparator' => ',',
        'thousandSeparator' => ' ',
        'currencyCode' => 'RUB',
        'locale' => 'ru-RU',           // по умолчанию — Yii::$app->language
        'timeZone' => 'Europe/Moscow', // по умолчанию — Yii::$app->timeZone
        'defaultTimeZone' => 'UTC',    // в каком поясе хранятся входные значения (2.0.1)
        'nullDisplay' => '—',
    ],
],

Для другого языка на лету — создать свой экземпляр: new Formatter(['locale' => 'en-US']), или переключить Yii::$app->language до обращения к компоненту.

Даты и время#

asDate($value, $format)
дата
asTime($value, $format)
время
asDatetime($value, $format)
дата и время
asTimestamp($value)
UNIX-время
asRelativeTime($value, $ref)
«5 minutes ago», «in 2 days»
asDuration($value)
интервал: «1 day, 2 hours»

Входное значение — UNIX-timestamp, строка (всё, что понимает strtotime(): '2014-01-01', 'now', '+1 day'), DateTime/DateTimeInterface, а с 2.0.1 — и строка вида 2014-01-01 00:00:00.

Форматы#

PHP
$formatter->asDate($v, 'short');            // 1/1/14      — short, medium (по умолчанию), long, full
$formatter->asDate($v, 'yyyy-MM-dd');       // синтаксис ICU
$formatter->asDate($v, 'php:Y-m-d');        // синтаксис PHP date()
$formatter->asDatetime($v, 'php:d.m.Y H:i');
ICU PHP Пример
yyyyY2014
MM / MMM / MMMMm / M / F01 / Jan / January
ddd05
EEE / EEEED / lMon / Monday
HH / hh aH / h A14 / 02 PM
mm, ssi, s05, 09

Часовые пояса#

Значения без явного пояса считаются заданными в defaultTimeZone (по умолчанию UTC) и переводятся в timeZone при выводе. Храните время в UTC — тогда достаточно выставить timeZone под пользователя. У DateTime со своим поясом преобразование учитывает его.

PHP
$formatter->timeZone = 'Europe/Berlin';
echo $formatter->asDatetime('2014-01-01 00:00:00');   // 1 января 2014, 01:00

Только даты (asDate) с 2.0.14 не сдвигаются часовым поясом, чтобы 1 января не превратилось в 31 декабря.

Числа#

asInteger($value)
целое с разделителем тысяч
asDecimal($value, $decimals, $options, $textOptions)
дробное; decimals — знаков после запятой
asPercent($value, $decimals)
проценты (0.125 → 12.5%)
asScientific($value)
1.25E+3
asCurrency($value, $currency)
деньги: «1 234,56 ₽»
asSpellout($value)
числа прописью (требует intl)
asOrdinal($value)
порядковое: 1st, 2nd
asShortSize($value, $decimals)
12.4 KB, asSize() — 12.4 kibibytes; sizeFormatBase (1024 или 1000)
asLength($value), asShortLength(), asWeight(), asShortWeight()
длина и вес в системе systemOfUnits (2.0.13)
PHP
$formatter->asInteger(1234567);          // 1,234,567
$formatter->asDecimal(1234.5678, 2);     // 1,234.57
$formatter->asCurrency(1234.5, 'EUR');   // €1,234.50
$formatter->asShortSize(1024 * 1024);    // 1 MB (sizeFormatBase = 1024)
$formatter->numberFormatterOptions = [
    NumberFormatter::MIN_FRACTION_DIGITS => 0,
    NumberFormatter::MAX_FRACTION_DIGITS => 2,
];
$formatter->numberFormatterSymbols = [NumberFormatter::CURRENCY_SYMBOL => '€'];

Настройки NumberFormatter (numberFormatterOptions, numberFormatterTextOptions, numberFormatterSymbols) применяются ко всем числовым форматам; те же ключи можно передать аргументами $options/$textOptions в конкретный вызов.

Прочее#

asRaw($value)
как есть (только nullDisplay)
asText($value)
с HTML-экранированием
asNtext($value)
экранирование + переводы строк → <br>
asParagraphs($value)
абзацы → <p>
asHtml($value, $config)
HTML, очищенный через HtmlPurifier
asEmail($value, $options)
ссылка mailto:
asUrl($value, $options)
ссылка <a>
asImage($value, $options)
<img>
asBoolean($value)
booleanFormat (['Нет', 'Да'])

Где применяется автоматически#

PHP
// GridView и DetailView понимают формат в описании колонки
'columns' => [
    'created_at:datetime',
    'price:currency',
    ['attribute' => 'size', 'format' => ['shortSize', 1]],
    ['attribute' => 'is_active', 'format' => 'boolean'],
    'email:email',
    'description:ntext',
],

По умолчанию колонки выводятся форматом text (экранирование). Чтобы вывести HTML — формат raw или html.

Проверь себя5

$formatter->asDate($v, 'php:d.m.Y') или ICU 'dd.MM.yyyy', либо задать dateFormat в конфигурации компонента.

Значение nullDisplay — по умолчанию (not set).

Суффиксом после атрибута: 'created_at:datetime', 'price:currency', или ключом format в конфигурации колонки.

Значение считается заданным в defaultTimeZone (UTC) и переводится в timeZone форматтера — например, Europe/Berlin (+1).

Форматирование работает, но без локализации: английские названия, US-разделители, нет asSpellout()/asOrdinal().

31Провайдеры данных, пагинация, сортировка

ActiveDataProvider, SqlDataProvider, ArrayDataProvider и свой провайдер; классы Pagination и Sort — как они читают параметры из URL, строят ссылки и подключаются к запросам; LinkPager и ссылки сортировки вручную.

Провайдер данных — объект DataProviderInterface, который отдаёт страницу моделей с учётом пагинации и сортировки из параметров запроса. Его принимают GridView, ListView, REST-контроллеры. Вы описываете источник (запрос AR, SQL или массив) — остальное делают классы Pagination и Sort.

Три провайдера#

PHP
use yii\data\ActiveDataProvider;

$query = Post::find()->where(['status' => 1]);
$provider = new ActiveDataProvider([
    'query' => $query,
    'pagination' => ['pageSize' => 10],
    'sort' => ['defaultOrder' => ['created_at' => SORT_DESC, 'title' => SORT_ASC]],
]);

$posts = $provider->getModels();   // страница моделей
$provider->getTotalCount();        // всего строк (отдельный COUNT)
$provider->getKeys();              // первичные ключи страницы
$provider->getCount();             // моделей на странице

Принимает любой QueryInterface, в том числе обычный Query — тогда модели будут массивами. Сортировка по умолчанию настроена по атрибутам модели. Соединение — db, можно задать другое.

PHP
use yii\data\SqlDataProvider;

$count = Yii::$app->db
    ->createCommand('SELECT COUNT(*) FROM post WHERE status=:status', [':status' => 1])
    ->queryScalar();

$provider = new SqlDataProvider([
    'sql' => 'SELECT * FROM post WHERE status=:status',
    'params' => [':status' => 1],
    'totalCount' => $count,              // обязательно — сам провайдер считать не умеет
    'sort' => ['attributes' => ['title', 'view_count', 'created_at']],
    'pagination' => ['pageSize' => 20],
]);
$rows = $provider->getModels();          // массивы

Провайдер сам допишет ORDER BY, LIMIT и OFFSET к вашему SQL.

PHP
use yii\data\ArrayDataProvider;

$provider = new ArrayDataProvider([
    'allModels' => $data,                // массив массивов или объектов
    'key' => 'id',                       // ключ (по умолчанию индекс)
    'pagination' => ['pageSize' => 10],
    'sort' => ['attributes' => ['id', 'username', 'email']],
]);

Сортирует и режет массив в памяти — подходит для небольших и уже выбранных данных. Для БД используйте первые два: там LIMIT применяется в запросе.

Ключи#

Каждой модели соответствует ключ (getKeys()), который GridView кладёт в data-key строк, а CheckboxColumn отправляет на сервер. Для AR это первичный ключ (составной — сериализуется), для SqlDataProvider и ArrayDataProvider — свойство key (имя столбца или замыкание), иначе индекс.

Pagination#

Хранит totalCount, pageSize, page и читает их из запроса:

PHP
use yii\data\Pagination;

$query = Article::find()->where(['status' => 1]);
$countQuery = clone $query;
$pages = new Pagination(['totalCount' => $countQuery->count(), 'pageSize' => 20]);
$models = $query->offset($pages->offset)->limit($pages->limit)->all();

// в представлении
echo LinkPager::widget(['pagination' => $pages]);
pageParam, pageSizeParam
имена GET-параметров (page, per-page)
pageSize, defaultPageSize, pageSizeLimit
размер страницы: из запроса, но в пределах [1, 50] по умолчанию; pageSizeLimit = false — без ограничений
page
номер страницы, считается с 0 (в URL — с 1, validatePage обрезает по pageCount)
forcePageParam
добавлять page=1 даже для первой (по умолчанию true)
route, params, urlManager
как строить ссылки; по умолчанию текущий маршрут и все GET-параметры
pageCount, offset, limit, getLinks()
вычисляемые
createUrl($page), getPageCount()
вручную
PHP
$pages->createUrl(2);          // /index.php?r=article%2Findex&page=3
$pages->getLinks();            // ['self' => …, 'first' => …, 'prev' => …, 'next' => …, 'last' => …] — для REST

В провайдере: 'pagination' => false отключает пагинацию, ['pageSize' => 0] — без LIMIT, но с ссылками.

LinkPager#

PHP
echo LinkPager::widget([
    'pagination' => $provider->pagination,
    'maxButtonCount' => 5,
    'firstPageLabel' => '«', 'lastPageLabel' => '»',
    'prevPageLabel' => '‹', 'nextPageLabel' => '›',
    'hideOnSinglePage' => true,
    'options' => ['class' => 'pagination justify-content-center'],
    // Bootstrap 4/5
    'linkContainerOptions' => ['class' => 'page-item'], 'linkOptions' => ['class' => 'page-link'],
]);

Sort#

Читает GET-параметр sort (?sort=age,-name — минус означает DESC), строит ORDER BY и ссылки:

PHP
use yii\data\Sort;

$sort = new Sort([
    'attributes' => [
        'age',                                           // просто столбец
        'name' => [
            'asc' => ['first_name' => SORT_ASC, 'last_name' => SORT_ASC],
            'desc' => ['first_name' => SORT_DESC, 'last_name' => SORT_DESC],
            'default' => SORT_DESC,
            'label' => 'Имя',
        ],
    ],
    'defaultOrder' => ['age' => SORT_ASC],
    'enableMultiSort' => true,       // несколько атрибутов сразу (по умолчанию false)
]);

$models = Article::find()->orderBy($sort->orders)->all();

// ссылки в представлении
echo $sort->link('name') . ' | ' . $sort->link('age');
echo $sort->link('age', ['class' => 'sort-link']);
$sort->createUrl('age');
$sort->getAttributeOrder('age');   // SORT_ASC | SORT_DESC | null
attributes
что можно сортировать; строка = столбец, массив = правила для asc/desc, default, label
defaultOrder
если параметр не передан
enableMultiSort, separator (,)
несколько атрибутов
sortParam
имя GET-параметра (sort)
route, params, urlManager
построение ссылок
orders
итоговый ORDER BY в формате ['col' => SORT_ASC]
attributeOrders
текущая сортировка по атрибутам

Ссылки link() получают класс asc/desc по текущему направлению — удобно рисовать стрелки CSS.

Сортировка по связи в ActiveDataProvider#

Провайдер знает только атрибуты модели; связанные столбцы добавляются вручную:

PHP
$query = Post::find()->joinWith('author');
$provider = new ActiveDataProvider(['query' => $query]);
$provider->sort->attributes['author.name'] = [
    'asc' => ['user.name' => SORT_ASC],
    'desc' => ['user.name' => SORT_DESC],
];

Тот же приём нужен для фильтров GridView по связям — см. виджеты данных.

Свой провайдер#

PHP
class CsvDataProvider extends \yii\data\BaseDataProvider
{
    public $filename;

    protected function prepareModels()
    {
        // прочитать файл с учётом $this->getPagination()->offset/limit и $this->getSort()->orders
    }
    protected function prepareKeys($models) { return array_keys($models); }
    protected function prepareTotalCount() { return /* число строк */; }
}

Три метода — и всё остальное (getModels(), getKeys(), getTotalCount(), кэширование результата) уже есть в BaseDataProvider.

Несколько провайдеров на странице#

Чтобы page и sort одного списка не влияли на другой, дайте параметрам разные имена:

PHP
$providerA = new ActiveDataProvider([
    'query' => Post::find(),
    'pagination' => ['pageParam' => 'post-page'],
    'sort' => ['sortParam' => 'post-sort'],
]);

Или задайте id провайдеру — тогда параметры станут id-page и id-sort автоматически.

Проверь себя5

По объекту Query провайдер сам строит COUNT(*), а произвольный SQL он переделать не может.

В объекте — с 0, в URL-параметре page — с 1.

Сортировка по created_at по убыванию, затем по title по возрастанию (при enableMultiSort = true).

pageSizeLimit в Pagination (по умолчанию [1, 50]).

Сделать joinWith() в запросе и добавить атрибут в $provider->sort->attributes с asc/desc по столбцу связанной таблицы.

32Виджеты данных

DetailView для одной модели, ListView со своим шаблоном элемента, GridView с колонками DataColumn/ActionColumn/CheckboxColumn/SerialColumn, фильтрами, сортировкой и форматами; работа со связями (joinWith), Pjax, несколько таблиц на странице.

Три виджета, которые закрывают 90% вывода данных: DetailView — карточка одной записи, ListView — список произвольной вёрстки, GridView — таблица с сортировкой, фильтрами и пагинацией. Все берут данные из провайдера и форматируют через formatter.

DetailView#

PHP
use yii\widgets\DetailView;

echo DetailView::widget([
    'model' => $model,
    'attributes' => [
        'title',                                    // атрибут как есть (формат text)
        'description:html',                         // атрибут:формат
        'created_at:datetime',
        [
            'label' => 'Владелец',
            'value' => $model->owner->name,          // значение или замыкание function ($model, $widget)
        ],
        [
            'attribute' => 'status',
            'format' => 'raw',
            'value' => $model->status ? '<span class="badge">активен</span>' : 'нет',
        ],
        'owner.email',                              // через связь — вложенное свойство
        ['attribute' => 'secret', 'visible' => Yii::$app->user->can('admin')],
    ],
    'template' => '<tr><th>{label}</th><td>{value}</td></tr>',
    'options' => ['class' => 'table table-striped'],
]);

model может быть объектом или массивом. Форматы — любые из Formatter.

ListView#

PHP
use yii\widgets\ListView;

echo ListView::widget([
    'dataProvider' => $dataProvider,
    // views/post/_post.php, получает $model, $key, $index, $widget
    'itemView' => '_post',
    // 'itemView' => function ($model, $key, $index, $widget) { return Html::tag('div', $model->title); },
    'viewParams' => ['fullView' => true],           // дополнительно в $itemView
    'itemOptions' => ['class' => 'item'],
    'layout' => "{summary}\n{items}\n{pager}",      // также {sorter}
    'summary' => 'Показано {begin}–{end} из {totalCount}',
    'emptyText' => 'Ничего не найдено',
    'pager' => ['class' => LinkPager::class, 'maxButtonCount' => 5],
    'sorter' => ['attributes' => ['title', 'created_at']],
]);
views/post/_post.phpPHP
<article>
    <h2><?= Html::a(Html::encode($model->title), ['view', 'id' => $model->id]) ?></h2>
    <?= Yii::$app->formatter->asDate($model->created_at) ?>
</article>

GridView#

PHP
use yii\grid\GridView;

echo GridView::widget([
    'dataProvider' => $dataProvider,
    'filterModel' => $searchModel,                  // строка фильтров над таблицей
    'columns' => [
        ['class' => 'yii\grid\SerialColumn'],       // №
        'id',
        'name',
        'email:email',
        'created_at:date',
        ['class' => 'yii\grid\ActionColumn'],       // просмотр / правка / удаление
    ],
    'layout' => "{summary}\n{items}\n{pager}",
    'tableOptions' => ['class' => 'table table-striped'],
    'rowOptions' => function ($model, $key, $index, $grid) {
        return ['class' => $model->is_deleted ? 'text-muted' : ''];
    },
]);

Без columns виджет покажет все атрибуты модели.

DataColumn — обычная колонка#

Строка 'name:format:label' или массив:

PHP
[
    'attribute' => 'price',
    'label' => 'Цена',
    'format' => ['currency', 'RUB'],
    'value' => function ($model, $key, $index, $column) { return $model->price / 100; },   // или 'owner.name'
    'contentOptions' => ['class' => 'text-end'],
    'headerOptions' => ['style' => 'width: 100px'],
    'enableSorting' => true,
    // <select> вместо текстового поля; false — без фильтра
    'filter' => ['1' => 'Активен', '0' => 'Заблокирован'],
    'filterInputOptions' => ['class' => 'form-control', 'placeholder' => 'Поиск'],
    'visible' => !Yii::$app->user->isGuest,
    'content' => function ($model) { … },   // полностью свой HTML (не экранируется)
],

format => 'raw' выводит HTML без экранирования — только для доверенного содержимого.

ActionColumn#

PHP
[
    'class' => 'yii\grid\ActionColumn',
    'template' => '{view} {update} {delete} {custom}',
    'controller' => 'admin/user',                   // другой контроллер (по умолчанию текущий)
    'buttons' => [
        'custom' => function ($url, $model, $key) {
            return Html::a('<i class="bi bi-star"></i>', $url, ['title' => 'Избранное']);
        },
    ],
    'urlCreator' => function ($action, $model, $key, $index) {
        return Url::to([$action, 'id' => $model->id, 'slug' => $model->slug]);
    },
    'visibleButtons' => ['delete' => function ($model, $key, $index) { return $model->canDelete(); }],
],

Кнопка delete уже имеет data-confirm и data-method="post" — обрабатывается yii.js.

CheckboxColumn#

PHP
['class' => 'yii\grid\CheckboxColumn', 'checkboxOptions' => function ($model) { return ['value' => $model->id]; }],
JavaScript
var keys = $('#grid').yiiGridView('getSelectedRows');   // массив ключей отмеченных строк

Чекбокс в заголовке отмечает всё. Имя поля — selection[] (name колонки).

SerialColumn#

Порядковый номер строки с учётом страницы.

Фильтрация и поисковая модель#

Стандартный паттерн (Gii генерирует именно так):

models/PostSearch.phpPHP
class PostSearch extends Post
{
    public function rules()
    {
        return [[['id', 'status'], 'integer'], [['title', 'author.name'], 'safe']];
    }

    public function search($params)
    {
        $query = Post::find();
        $dataProvider = new ActiveDataProvider(['query' => $query]);

        if (!($this->load($params) && $this->validate())) {
            return $dataProvider;
        }

        $query->andFilterWhere(['id' => $this->id, 'status' => $this->status])
              ->andFilterWhere(['like', 'title', $this->title]);

        return $dataProvider;
    }
}
controllers/PostController.phpPHP
public function actionIndex()
{
    $searchModel = new PostSearch();
    $dataProvider = $searchModel->search(Yii::$app->request->queryParams);
    return $this->render('index', compact('searchModel', 'dataProvider'));
}

filterModel рисует поля фильтров по атрибутам с правилами; andFilterWhere игнорирует пустые — фильтр работает «как есть».

Фильтр и сортировка по связи#

models/PostSearch.phpPHP
public $authorName;                       // виртуальный атрибут для фильтра

public function rules() { return [[['authorName'], 'safe'], …]; }

public function search($params)
{
    $query = Post::find()->joinWith(['author']);          // JOIN нужен для where по чужой таблице
    $dataProvider = new ActiveDataProvider(['query' => $query]);

    $dataProvider->sort->attributes['authorName'] = [
        'asc' => ['user.name' => SORT_ASC],
        'desc' => ['user.name' => SORT_DESC],
    ];

    $this->load($params);
    $query->andFilterWhere(['like', 'user.name', $this->authorName]);
    return $dataProvider;
}
PHP
// колонка
['attribute' => 'authorName', 'value' => 'author.name', 'label' => 'Автор'],

Для hasMany при joinWith строки дублируются — добавьте ->groupBy('post.id') или используйте ->with() + подзапрос exists. Чтобы оптимизировать, joinWith('author', false) + with('author').

Pjax — таблица без перезагрузки#

PHP
<?php Pjax::begin(['id' => 'post-grid']) ?>
<?= GridView::widget([...]) ?>
<?php Pjax::end() ?>

Пагинация, сортировка и фильтры внутри работают через AJAX. Кнопки ActionColumn тоже, но после delete нужно обновить: $.pjax.reload({container: '#post-grid'}). Ссылки, которые не должны идти через Pjax, помечайте data-pjax="0".

Несколько GridView на странице#

PHP
$userProvider->pagination->pageParam = 'user-page';
$userProvider->sort->sortParam = 'user-sort';
$postProvider->pagination->pageParam = 'post-page';
$postProvider->sort->sortParam = 'post-sort';

Иначе страницы и сортировка будут делить одни и те же GET-параметры.

Быстрый рецепт CRUD-списка#

  1. PostSearch extends Post с search($params) — ActiveDataProvider + andFilterWhere.
  2. В действии: $dataProvider = $searchModel->search(Yii::$app->request->queryParams).
  3. GridView с filterModel, колонками и ActionColumn; обернуть в Pjax.
  4. Для связей — joinWith + виртуальный атрибут + sort->attributes.
  5. Массовые действия — CheckboxColumn + yiiGridView('getSelectedRows').
Проверь себя5

Атрибут created_at, формат datetime, заголовок «Создано».

Чтобы получить атрибуты, подписи и типы; rules() переопределяются под фильтр, а search() строит провайдер.

Условие where на столбец другой таблицы требует JOIN; with() делает отдельный запрос и не подходит.

$('#grid').yiiGridView('getSelectedRows') в JS или selection[] из POST.

Пагинация, сортировка и фильтрация обновляют только таблицу без перезагрузки страницы.

🔐 Безопасность

33Аутентификация

Компонент user и IdentityInterface — findIdentity, findIdentityByAccessToken, getId, getAuthKey, validateAuthKey; login/logout/isGuest/identity, «запомнить меня» через cookie, тайм-ауты сессии, события beforeLogin/afterLogin, returnUrl, доступ только для авторизованных.

Аутентификация отвечает на вопрос «кто это?». В Yii за неё отвечает компонент Yii::$app->user (класс yii\web\User), а сведения о пользователе хранит identity-класс, реализующий IdentityInterface. Проверка пароля — ваша, остальное (сессия, cookie, тайм-ауты, события) — фреймворка.

Настройка#

config/web.phpPHP
'components' => [
    'user' => [
        'identityClass' => 'app\models\User',
        'enableAutoLogin' => true,          // «запомнить меня» через cookie
        'loginUrl' => ['site/login'],       // куда отправлять гостей
        'authTimeout' => 3600,              // выйти после часа бездействия (null — пока живёт сессия)
        'absoluteAuthTimeout' => 86400,     // выйти через сутки в любом случае
        // 'enableSession' => false,        // для REST — без сессии, только по токену
        // 'identityCookie' => ['name' => '_identity', 'httpOnly' => true],
    ],
],

Identity-класс#

models/User.phpPHP
namespace app\models;

use yii\db\ActiveRecord;
use yii\web\IdentityInterface;

class User extends ActiveRecord implements IdentityInterface
{
    public static function tableName() { return 'user'; }

    public static function findIdentity($id)
    {
        return static::findOne($id);                          // по ID из сессии
    }

    public static function findIdentityByAccessToken($token, $type = null)
    {
        return static::findOne(['access_token' => $token]);   // для REST/API
    }

    public function getId() { return $this->id; }

    public function getAuthKey() { return $this->auth_key; }   // для cookie «запомнить меня»

    public function validateAuthKey($authKey) { return $this->getAuthKey() === $authKey; }

    // своё: проверка пароля и генерация ключа
    public function validatePassword($password)
    {
        return Yii::$app->security->validatePassword($password, $this->password_hash);
    }

    public function beforeSave($insert)
    {
        if (parent::beforeSave($insert)) {
            if ($this->isNewRecord) {
                $this->auth_key = Yii::$app->security->generateRandomString();
            }
            return true;
        }
        return false;
    }
}
findIdentity($id)
найти пользователя по ID; вызывается при каждом запросе с сессией
findIdentityByAccessToken($token, $type)
по токену (REST); $type — класс аутентификатора
getId()
уникальный ID, кладётся в сессию
getAuthKey()
секрет для cookie автологина; храните в БД, генерируйте при регистрации, меняйте при смене пароля
validateAuthKey($key)
сверка ключа из cookie

Identity не обязан быть Active Record — подойдёт любой класс (LDAP, внешний API, массив в конфиге).

Вход и выход#

PHP
// LoginForm::login()
public function login()
{
    if ($this->validate()) {
        return Yii::$app->user->login($this->getUser(), $this->rememberMe ? 3600 * 24 * 30 : 0);
    }
    return false;
}

// контроллер
public function actionLogin()
{
    if (!Yii::$app->user->isGuest) {
        return $this->goHome();
    }
    $model = new LoginForm();
    if ($model->load(Yii::$app->request->post()) && $model->login()) {
        return $this->goBack();          // на returnUrl или домой
    }
    return $this->render('login', ['model' => $model]);
}

public function actionLogout()
{
    Yii::$app->user->logout();           // logout(false) — не уничтожать сессию целиком
    return $this->goHome();
}

login($identity, $duration): при $duration > 0 и enableAutoLogin ставится cookie с ID + authKey на указанный срок. Пароль проверяется в вашем коде (validatePassword) до вызова login().

Текущий пользователь#

PHP
Yii::$app->user->isGuest;                // не вошёл?
Yii::$app->user->id;                     // ID или null
Yii::$app->user->identity;               // объект User или null
Yii::$app->user->identity->username;

Yii::$app->user->getReturnUrl();         // куда вернуть после логина
Yii::$app->user->setReturnUrl($url);
Yii::$app->user->loginRequired();        // редирект на loginUrl с сохранением returnUrl
// 403 вместо редиректа — если клиент по заголовку Accept не ждёт HTML (например, просит JSON)
Yii::$app->user->switchIdentity($newUser, $duration);   // сменить пользователя (например, «войти как»)

identity загружается лениво один раз за запрос — findIdentity() вызовется только если к нему обратились.

Доступ только для вошедших#

PHP
public function behaviors()
{
    return [
        'access' => [
            'class' => AccessControl::class,
            'only' => ['create', 'update', 'delete'],
            'rules' => [
                ['allow' => true, 'roles' => ['@']],   // '@' — аутентифицированные, '?' — гости
            ],
        ],
    ];
}

Гость получит редирект на loginUrl; после входа goBack() вернёт его туда, откуда он пришёл. Подробнее — в авторизации.

Как это работает под капотом#

  1. Первый запрос: user->login() кладёт getId() в сессию (__id) и, при $duration, ставит identity-cookie [id, authKey, duration].
  2. Следующие запросы: user->identity читает ID из сессии → findIdentity($id).
  3. Сессии нет, но есть cookie: сравнивается authKey через validateAuthKey() → авто-вход, сессия продлевается (autoRenewCookie).
  4. authTimeout/absoluteAuthTimeout проверяются на каждом запросе; истёк — logout().
  5. REST без сессии (enableSession = false): findIdentityByAccessToken() вызывает аутентификатор из фильтров (HttpBearerAuth и др.).

События#

PHP
'user' => [
    'on afterLogin' => function (\yii\web\UserEvent $event) {
        $event->identity->updateAttributes(['last_login_at' => time()]);
    },
],
beforeLogin
$event->isValid = false отменяет вход; $event->identity, $event->cookieBased, $event->duration
afterLogin
вход выполнен
beforeLogout / afterLogout
аналогично
Проверь себя5

findIdentity(), findIdentityByAccessToken(), getId(), getAuthKey(), validateAuthKey().

Ваш код: Yii::$app->security->validatePassword() до вызова Yii::$app->user->login().

enableAutoLogin = true, duration > 0 в login() и корректные getAuthKey()/validateAuthKey().

Первый — выход после N секунд бездействия (продлевается каждым запросом), второй — через N секунд после входа независимо от активности.

Yii::$app->user->loginRequired() (или AccessControl) сохраняет returnUrl; после входа — $this->goBack().

34Авторизация: ACF и RBAC

Фильтр AccessControl с правилами allow/deny по ролям, IP, методам и колбэкам; RBAC — роли, разрешения, иерархия, правила (Rule), PhpManager и DbManager, консольная инициализация, назначение ролей, проверка can(), роли по умолчанию.

Аутентификация сказала, кто пользователь; авторизация решает, что ему можно. Два инструмента: ACF (Access Control Filter) — простые правила у контроллера, и RBAC — управление доступом на основе ролей с иерархией и правилами, когда прав много и они зависят от данных.

Access Control Filter#

PHP
use yii\filters\AccessControl;

class SiteController extends Controller
{
    public function behaviors()
    {
        return [
            'access' => [
                'class' => AccessControl::class,
                'only' => ['login', 'logout', 'signup'],   // к каким действиям применять
                'rules' => [
                    [
                        'allow' => true,
                        'actions' => ['login', 'signup'],
                        'roles' => ['?'],                   // только гости
                    ],
                    [
                        'allow' => true,
                        'actions' => ['logout'],
                        'roles' => ['@'],                   // только вошедшие
                    ],
                ],
                'denyCallback' => function ($rule, $action) {
                    throw new \Exception('Вам сюда нельзя');
                },
            ],
        ];
    }
}

Правила проверяются сверху вниз до первого совпадения. Совпало allow — пропустить, deny — запретить. Ни одно не совпало — запретить (гостю — редирект на логин, вошедшему — 403).

Опции правила#

allow
true разрешить / false запретить
actions
ID действий; пусто — все
controllers
ID контроллеров (с модулем: admin/user); пусто — текущий
roles
? гость, @ аутентифицирован, иначе — роль/разрешение RBAC (Yii::$app->user->can())
roleParams
параметры для can() (массив или замыкание)
ips
IP клиента, с * в конце (192.168.*)
verbs
HTTP-методы (GET, POST)
matchCallback
function ($rule, $action) — любое условие
denyCallback
что делать при запрете (переопределяет общий)
PHP
[
    'allow' => true,
    'actions' => ['special-callback'],
    'matchCallback' => function ($rule, $action) {
        return date('d-m') === '31-10';   // только в Хэллоуин
    },
],
[
    'allow' => true,
    'actions' => ['update'],
    'roles' => ['updatePost'],
    'roleParams' => function ($rule) {
        return ['post' => Post::findOne(['id' => Yii::$app->request->get('id')])];
    },
],

RBAC#

Модель: разрешение (permission) — действие («создать пост»), роль (role) — набор разрешений и других ролей, правило (rule) — дополнительная проверка с параметрами («автор ли пользователь этого поста»). Пользователю назначаются роли/разрешения; проверка — Yii::$app->user->can('updatePost', ['post' => $post]).

Настройка#

PHP
'components' => [
    'authManager' => [
        'class' => 'yii\rbac\DbManager',     // таблицы: auth_item, auth_item_child, auth_assignment, auth_rule
        // 'class' => 'yii\rbac\PhpManager', // файлы в @app/rbac — для небольших статичных схем
        // 'defaultRoles' => ['guest'],
        // 'cache' => 'cache',               // кэшировать иерархию (DbManager)
    ],
],

Для DbManager: ./yii migrate --migrationPath=@yii/rbac/migrations. Компонент должен быть в обеих конфигурациях — web и console.

Построение иерархии#

commands/RbacController.phpPHP
class RbacController extends \yii\console\Controller
{
    public function actionInit()
    {
        $auth = Yii::$app->authManager;
        $auth->removeAll();

        // разрешения
        $createPost = $auth->createPermission('createPost');
        $createPost->description = 'Создание постов';
        $auth->add($createPost);

        $updatePost = $auth->createPermission('updatePost');
        $auth->add($updatePost);

        // роли
        $author = $auth->createRole('author');
        $auth->add($author);
        $auth->addChild($author, $createPost);

        $admin = $auth->createRole('admin');
        $auth->add($admin);
        $auth->addChild($admin, $updatePost);
        $auth->addChild($admin, $author);          // admin наследует всё от author

        // назначения (обычно — при регистрации)
        $auth->assign($author, 2);
        $auth->assign($admin, 1);
    }
}
Консоль
./yii rbac/init

Иерархия: admin → author → createPost; admin → updatePost. can('createPost') для admin вернёт true через цепочку.

Правила — доступ с учётом данных#

«Автор может редактировать свой пост»:

rbac/AuthorRule.phpPHP
namespace app\rbac;

use yii\rbac\Rule;

class AuthorRule extends Rule
{
    public $name = 'isAuthor';

    public function execute($user, $item, $params)
    {
        return isset($params['post']) ? $params['post']->createdBy == $user : false;
    }
}
PHP
$rule = new \app\rbac\AuthorRule();
$auth->add($rule);

$updateOwnPost = $auth->createPermission('updateOwnPost');
$updateOwnPost->description = 'Редактирование своего поста';
$updateOwnPost->ruleName = $rule->name;
$auth->add($updateOwnPost);

$auth->addChild($updateOwnPost, $updatePost);   // updateOwnPost «использует» updatePost
$auth->addChild($author, $updateOwnPost);
PHP
// проверка
if (Yii::$app->user->can('updatePost', ['post' => $post])) { … }

Для автора цепочка author → updateOwnPost(правило) → updatePost — правило проверит createdBy. Для admin путь admin → updatePost без правила — можно всегда.

Назначение ролей#

PHP
$auth->assign($auth->getRole('author'), $user->id);       // при регистрации — в afterSave или в контроллере
$auth->revoke($role, $userId);
$auth->revokeAll($userId);
$auth->getRolesByUser($userId);
$auth->getPermissionsByUser($userId);
$auth->getUserIdsByRole('admin');
$auth->checkAccess($userId, 'updatePost', $params);        // то же, что user->can(), но для любого ID

Роли по умолчанию#

Роль, которая есть у всех (в т.ч. гостей) без записи в auth_assignment — через defaultRoles и правило:

PHP
class UserGroupRule extends Rule
{
    public $name = 'userGroup';

    public function execute($user, $item, $params)
    {
        if (Yii::$app->user->isGuest) return false;
        $group = Yii::$app->user->identity->group;
        return $item->name === 'admin' ? $group == 1 : ($item->name === 'author' ? $group == 1 || $group == 2 : false);
    }
}

// authManager: 'defaultRoles' => ['admin', 'author'],

Так роль определяется полем group пользователя, а не таблицей назначений.

Проверка в коде#

PHP
if (Yii::$app->user->can('createPost')) { … }              // роль или разрешение
if (Yii::$app->user->can('updatePost', ['post' => $post])) { … }
if (!Yii::$app->user->can('admin')) {
    throw new \yii\web\ForbiddenHttpException('Нет доступа');
}

// в представлении
<?php if (Yii::$app->user->can('updatePost', ['post' => $model])): ?>
    <?= Html::a('Редактировать', ['update', 'id' => $model->id]) ?>
<?php endif ?>

can() для гостя (user->id === null) проверяет только defaultRoles.

ACF или RBAC?#

ACF
  • права зависят только от «гость/вошёл», IP, метода, действия;
  • маленькое приложение, 1–2 роли;
  • нужно быстро закрыть контроллер.
RBAC
  • много ролей и разрешений, иерархия;
  • доступ зависит от данных (свой/чужой объект);
  • права меняются администратором без деплоя (DbManager).

Они совместимы: в roles правила ACF пишут имена RBAC-элементов, и фильтр вызовет can().

Проверь себя5

Доступ запрещён: гость — редирект на loginUrl, вошедший — ForbiddenHttpException (403).

? — неаутентифицированный пользователь, @ — аутентифицированный.

Разрешение — конкретное действие; роль — набор разрешений и ролей. Проверять лучше разрешения, назначать — роли.

Для условий, зависящих от параметров: например, updateOwnPost проверяет, что автор поста — текущий пользователь.

Через defaultRoles менеджера и правило, определяющее роль по данным пользователя.

35Безопасность

Компонент security — хэширование паролей (password_hash/bcrypt), генерация случайных данных, шифрование и подпись (encryptByPassword/encryptByKey, hashData); защита от SQL-инъекций, XSS, CSRF, подделки файлов и утечек конфигурации; практики безопасной настройки сервера и cookie.

Yii закрывает типовые дыры по умолчанию: параметры в SQL, CSRF-токены в формах, подписанные cookie, экранирование в Html::encode(). Ваша часть — не отключать защиту, правильно хранить пароли и не доверять входным данным. Всё криптографическое — в Yii::$app->security.

Пароли#

PHP
// при регистрации / смене пароля
$hash = Yii::$app->security->generatePasswordHash($password);   // bcrypt, соль внутри — хранить в password_hash
// при входе
if (Yii::$app->security->validatePassword($password, $hash)) { … }

Никаких md5()/sha1(): bcrypt медленный намеренно (passwordHashCost, по умолчанию 13). Пароль в открытом виде не хранится и не логируется. Для сброса пароля — случайный токен с ограниченным сроком (generateRandomString() + время).

Случайные данные#

PHP
$key = Yii::$app->security->generateRandomString();     // 32 символа [A-Za-z0-9_-] — токены, authKey, соли
$key = Yii::$app->security->generateRandomString(64);
$bytes = Yii::$app->security->generateRandomKey(32);    // сырые байты

Используют random_bytes() — криптографически стойко. rand()/mt_rand()/uniqid() для секретов не годятся.

Шифрование и подпись#

PHP
// шифрование по паролю (медленнее: пароль растягивается через PBKDF2)
$encrypted = Yii::$app->security->encryptByPassword($data, $secretKey);
$data = Yii::$app->security->decryptByPassword($encrypted, $secretKey);

// шифрование по ключу (ключ — случайные байты подходящей длины)
// $info — контекст (например, ID пользователя)
$encrypted = Yii::$app->security->encryptByKey($data, $key, $info);
$data = Yii::$app->security->decryptByKey($encrypted, $key, $info);

// подпись: гарантирует, что данные не изменены
$signed = Yii::$app->security->hashData($data, $key);      // hash + data
$data = Yii::$app->security->validateData($signed, $key);   // данные или false

// производные ключи
Yii::$app->security->hkdf('sha256', $inputKey, $salt, $info, $length);
Yii::$app->security->pbkdf2('sha256', $password, $salt, $iterations, $length);
Yii::$app->security->compareString($expected, $actual);     // сравнение за постоянное время
Yii::$app->security->maskToken($token); unmaskToken();       // защита от BREACH — так работают CSRF-токены

Шифрование — AES-128-CBC с HMAC (шифр настраивается: cipher). Ключи храните вне репозитория (переменные окружения, файлы вне webroot).

Типичные угрозы и защита#

SQL-инъекции#

PHP
// плохо
$db->createCommand("SELECT * FROM user WHERE username = '$username'");
// хорошо
$db->createCommand('SELECT * FROM user WHERE username = :username', [':username' => $username]);
User::find()->where(['username' => $username]);            // Query Builder и AR привязывают сами
User::findOne(['id' => (int)$id]);                         // а не findOne($id) с пользовательскими данными

Имена столбцов и таблиц параметрами не передаются — только белый список.

XSS#

PHP
<?= Html::encode($user->name) ?>                            // всегда для «чужого» текста
<?= HtmlPurifier::process($post->body) ?>                    // если нужен HTML (медленно — кэшируйте)
<?= Yii::$app->formatter->asHtml($post->body) ?>             // то же через форматтер

GridView/DetailView экранируют по умолчанию (формат text); raw — только для доверенного HTML. Twig и Smarty — тоже экранируют автоматически.

CSRF#

Включена по умолчанию: ActiveForm и Html::beginForm() добавляют скрытое поле _csrf, yii.js шлёт заголовок X-CSRF-Token в AJAX. Правила:

  • GET не меняет состояние — удаление через ссылку ?action=delete уязвимо; используйте POST (data-method="post") или VerbFilter.
  • Отключать только точечно: $enableCsrfValidation = false в контроллере или beforeAction() для webhook-эндпоинтов.
  • Свой AJAX-код: yii.getCsrfToken() или мета-теги <?= Html::csrfMetaTags() ?>.
PHP
public function beforeAction($action)
{
    if ($action->id === 'webhook') {
        $this->enableCsrfValidation = false;
    }
    return parent::beforeAction($action);
}

Файлы#

  • Не отдавать файлы по пути из запроса (../../etc/passwd); проверяйте realpath() и белый список.
  • Загрузки — только в папку без выполнения PHP; расширение проверять валидатором file с checkExtensionByMimeType; имя генерировать своё.
  • Скрывать листинг каталогов и точечные файлы в конфигурации сервера.

Утечки через отладку#

  • YII_DEBUG = false и YII_ENV = 'prod' на production: страница ошибки не покажет стек и код.
  • Модули debug и gii — только в dev; в production они опасны даже с allowedIPs.
  • Логи не должны содержать паролей и токенов.
  • cookieValidationKey в конфигурации request — обязателен, длинный, случайный, вне репозитория; иначе cookie не подписаны.
  • Cookie-флаги: httpOnly (по умолчанию true), secure для HTTPS, sameSite (2.0.21): 'sameSite' => Cookie::SAME_SITE_LAX.
  • Сессии: не хранить в PHP-файлах на shared-хостинге — DbSession/CacheSession; менять ID при входе (Yii::$app->session->regenerateID() вызывается фреймворком при login()).
  • Токены в URL не передавать: попадают в логи и Referer.

Настройка сервера#

PHP
// проверка Host-заголовка: иначе подмена в ссылках и письмах
'request' => [
    'hostInfo' => 'https://example.com',       // жёстко
    // или 'trustedHosts' => ['example.com', '*.example.com'],
],
  • Веб-сервер должен отдавать только web/; config/, runtime/, vendor/ — вне document root (так и в шаблонах).
  • HTTPS везде; за прокси — trustedHosts + secureHeaders, чтобы isSecureConnection и IP определялись верно.
  • php.ini: expose_php = Off, display_errors = Off на production.
  • Обновляйте фреймворк и расширения — composer outdated; следите за уязвимостями.

Чек-лист перед релизом#

Пароли

generatePasswordHash() / validatePassword(), никакого md5

Секреты

cookieValidationKey, ключи шифрования и БД вне git

Ввод

валидация всех моделей, Html::encode() в выводе, параметры в SQL

CSRF

включена; действия, меняющие данные, — только POST/DELETE

Ошибки

YII_DEBUG=false, YII_ENV=prod, errorAction настроен

Файлы

загрузки вне webroot или без исполнения, имена свои

Cookies

httpOnly, secure, sameSite; сессии не в /tmp

Хост

hostInfo/trustedHosts, HTTPS, актуальные версии зависимостей

Проверь себя5

Yii::$app->security->generatePasswordHash($password) (bcrypt) в password_hash; проверять validatePassword().

Массив в id превращается в условие; приводите к int или пишите findOne(['id' => $id]).

При выводе любых данных, пришедших от пользователя или из БД, если это не заведомо безопасный HTML.

В beforeAction() контроллера: $this->enableCsrfValidation = false для нужного $action->id.

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

⚡ Кеширование

36Кэширование данных

Компонент cache и его хранилища (File, Apcu, Memcached, Redis, Db, Array, Dummy), API get/set/add/delete/getOrSet, множественные операции, зависимости (Tag, File, Expression, Db, Chained), кэширование запросов к БД (cache()/noCache()), ключи и префиксы.

Кэширование в Yii — четыре уровня: данные (любое значение по ключу), фрагменты страниц, страницы целиком и HTTP-кэш на стороне клиента. Все они опираются на компонент cache с единым API, поэтому хранилище можно сменить конфигурацией — код не изменится.

Хранилища#

PHP
'components' => [
    'cache' => [
        'class' => 'yii\caching\FileCache',                 // файлы в @runtime/cache
        // APCu — быстрее всего, но локально и в памяти процесса
        // 'class' => 'yii\caching\ApcCache',
        // 'class' => 'yii\caching\MemCache', 'servers' => [['host' => 'server1', 'port' => 11211, 'weight' => 100]],
        // 'class' => 'yii\redis\Cache',                    // расширение yii2-redis
        // 'class' => 'yii\caching\DbCache', 'cacheTable' => 'cache',   // таблица в БД
        // 'class' => 'yii\caching\ArrayCache',             // на время запроса (тесты, dev)
        // 'class' => 'yii\caching\DummyCache',             // заглушка: код с кэшем работает, но не кэширует
        'keyPrefix' => 'myapp',                             // разделить приложения на одном сервере
        'defaultDuration' => 3600,
        // 'serializer' => false,                           // для хранилищ с собственной сериализацией
    ],
],
Класс Где хранит Когда
FileCacheфайлыпо умолчанию; один сервер
ApcCacheAPCu, память PHPсамый быстрый; сбрасывается при рестарте; не для CLI
MemCachememcachedнесколько серверов
yii\redis\CacheRedisнесколько серверов, персистентность
DbCacheтаблица БДкогда нет ничего другого
ArrayCacheмассиввнутри одного запроса
DummyCacheнигдеотключить кэш, не меняя код
WinCache, XCache, ZendDataCacheустаревшие—

Можно объявить несколько компонентов (cache, apcCache) — быстрый для мелкого, распределённый для общего.

API#

PHP
$cache = Yii::$app->cache;

$cache->set($key, $data, $duration, $dependency);   // записать; duration 0 — навсегда
$cache->get($key);                                  // значение или false
$cache->exists($key);
$cache->add($key, $data);                           // только если ключа ещё нет
$cache->delete($key);
$cache->flush();                                    // всё

// «получить или вычислить и сохранить» — самый частый паттерн
$data = $cache->getOrSet($key, function () {
    return Post::find()->where(['status' => 1])->all();
}, 3600, $dependency);

// несколько ключей
$cache->multiSet(['k1' => 'v1', 'k2' => 'v2'], 600);
$cache->multiGet(['k1', 'k2']);
$cache->multiAdd([...]);

Хранить можно любое сериализуемое значение (объекты, массивы). false кэшировать не стоит — неотличимо от промаха; оборачивайте в массив.

Ключи#

Ключ — строка или массив (сериализуется и хэшируется):

PHP
$key = [__CLASS__, 'posts', $userId, $page];    // безопасно и читаемо
$cache->set($key, $data);

keyPrefix добавляется автоматически, чтобы несколько приложений не мешали друг другу в одном memcached/Redis.

Зависимости#

Кэш инвалидируется не только по времени, но и по условию:

PHP
use yii\caching\{TagDependency, FileDependency, DbDependency, ExpressionDependency, ChainedDependency};

// по тегу — самый гибкий способ
$cache->set('posts', $posts, 0, new TagDependency(['tags' => ['posts', 'homepage']]));
TagDependency::invalidate($cache, 'posts');          // сбросить всё с этим тегом (например, в afterSave поста)

// по файлу: изменился mtime — кэш устарел
new FileDependency(['fileName' => 'example.txt']);

// по результату SQL
new DbDependency(['sql' => 'SELECT MAX(updated_at) FROM post']);

// по PHP-выражению
new ExpressionDependency(['expression' => 'Yii::$app->language']);

// несколько сразу
new ChainedDependency(['dependencies' => [$dep1, $dep2]]);

При get() зависимость пересчитывается; если изменилась — запись считается отсутствующей. reusable => true у зависимости позволяет пересчитать её один раз за запрос.

Кэширование запросов к БД#

PHP
$db = Yii::$app->db;
$duration = 60;
$dependency = new DbDependency(['sql' => 'SELECT MAX(updated_at) FROM customer']);

$result = $db->cache(function ($db) use ($dependency) {
    return $db->createCommand('SELECT * FROM customer WHERE id=1')->queryOne();   // будет закэширован
}, $duration, $dependency);

// исключить часть запросов внутри cache()
$result = $db->cache(function ($db) {
    $r1 = …;                                    // кэшируется
    $db->noCache(function ($db) { … });        // нет
    return $r1;
});

// один запрос AR / Query
$customers = Customer::find()->where(['status' => 1])->cache(60, $dependency)->all();
$rows = (new Query())->from('user')->cache(120)->all();

// для DAO-команды
$db->createCommand($sql)->cache($duration)->queryAll();

Настройки соединения: enableQueryCache (по умолчанию true), queryCacheDuration (3600), queryCache (ID компонента кэша). Кэш работает для query*(), но не для execute(), и не для ресурсов (курсоры). Кэшируется результат по SQL + параметрам, поэтому одинаковые запросы дают попадание.

Консольные команды#

Консоль
./yii cache                 # список компонентов кэша
./yii cache/flush cache     # очистить указанный
./yii cache/flush-all
./yii cache/flush-schema    # кэш схемы БД

Внимание: команды видят компоненты из консольной конфигурации; ApcCache из CLI не очистить — память веб-процессов другая.

Где что кэшировать#

Результаты тяжёлых выборок, агрегаты, меню, справочники
getOrSet() с TagDependency, сброс в afterSave()/afterDelete() модели
Ответы внешних API
set() с коротким duration
Запросы AR в списках
->cache() на запросе
Схема таблиц
enableSchemaCache
Сессии, RBAC-иерархия (authManager.cache), URL-правила (urlManager.cache)
те же компоненты кэша
Проверь себя5

false — поэтому не кэшируйте само значение false, а оборачивайте в массив.

Записывать с TagDependency(['tags' => 'posts']) и вызывать TagDependency::invalidate(Yii::$app->cache, 'posts') в afterSave().

Post::find()->where(…)->cache(60)->all().

Чтобы несколько приложений в одном memcached/Redis/APCu не перезаписывали ключи друг друга.

Позволяет писать код с кэшем как обычно, но реально ничего не хранить — например, в dev или тестах.

37Кэширование фрагментов, страниц и HTTP

beginCache()/endCache() с duration, dependency, variations и enabled; вложенные фрагменты и динамическое содержимое renderDynamic(); фильтр PageCache; HTTP-кэш через HttpCache — Last-Modified, ETag, Cache-Control, а также session cache limiter и SEO-эффект.

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

Фрагменты#

PHP
<?php if ($this->beginCache($id, $options)) { ?>
    ...содержимое, которое дорого генерировать...
<?php $this->endCache(); } ?>

beginCache() вернул true — кэша нет, содержимое генерируется и сохраняется; false — кэш есть, он уже выведен, тело пропускается.

Опции#

PHP
$options = [
    'duration' => 3600,                                  // по умолчанию 60 секунд; 0 — навсегда
    'dependency' => [
        'class' => 'yii\caching\DbDependency',
        'sql' => 'SELECT COUNT(*) FROM post',
    ],
    'variations' => [Yii::$app->language, Yii::$app->user->id],   // отдельный кэш для каждой комбинации
    'enabled' => !YII_ENV_DEV,                           // включать по условию
    'cache' => 'cache',                                  // компонент
];
duration
секунды жизни
dependency
объект или конфигурация зависимости (Tag, Db, File, Expression, Chained)
variations
массив значений, которые влияют на вывод (язык, пользователь, тема); входят в ключ
enabled
false — просто выводить без кэша (удобно для отладки)
cache
имя компонента кэша

Вложенные фрагменты#

PHP
<?php if ($this->beginCache($id1)) { ?>
    ...внешний фрагмент...
    <?php if ($this->beginCache($id2, $options2)) { ?>
        ...внутренний...
    <?php $this->endCache(); } ?>
    ...
<?php $this->endCache(); } ?>

Внутренний может иметь короче срок, чем внешний, но пока жив внешний — внутренний не пересоберётся (он часть внешнего HTML).

Динамическое содержимое внутри кэша#

PHP
<?php if ($this->beginCache($id)) { ?>
    ...статичная часть...
    <?= $this->renderDynamic('return Yii::$app->user->identity->name;') ?>
    <?= $this->renderDynamic(function ($view, $params) { return date('H:i:s'); }) ?>
<?php $this->endCache(); } ?>

renderDynamic() вставляет плейсхолдер, который выполняется при каждом показе — даже когда фрагмент из кэша. Код передаётся строкой PHP или замыканием (замыкание — с 2.0.14).

Кэширование страниц#

Фильтр PageCache в контроллере — кэш целого ответа, включая заголовки:

PHP
public function behaviors()
{
    return [
        [
            'class' => 'yii\filters\PageCache',
            'only' => ['index'],
            'duration' => 60,
            'variations' => [Yii::$app->language],
            'dependency' => ['class' => 'yii\caching\DbDependency', 'sql' => 'SELECT COUNT(*) FROM post'],
            // 'enabled' => …, 'cache' => 'cache', 'cacheCookies' => false, 'cacheHeaders' => true
        ],
    ];
}

Работает как фрагмент, но на уровне beforeAction(): при попадании действие не выполняется вовсе. Кэшируется только успешный ответ (statusCode == 200); cookie и заголовки — настраиваемо. variations — обязательно добавьте всё, что меняет страницу (пользователь, язык), иначе гость увидит чужие данные.

HTTP-кэширование#

Не генерировать ответ, если у клиента уже свежая копия. Фильтр HttpCache:

PHP
public function behaviors()
{
    return [
        [
            'class' => 'yii\filters\HttpCache',
            'only' => ['index'],
            'lastModified' => function ($action, $params) {
                return (new \yii\db\Query())->from('post')->max('updated_at');   // timestamp
            },
            'etagSeed' => function ($action, $params) {
                // что угодно, что меняется с содержимым
                return serialize([$this->page->title, $this->page->content]);
            },
            'cacheControlHeader' => 'public, max-age=3600',
            'sessionCacheLimiter' => 'public',
            // 'params' => [...], 'enabled' => true
        ],
    ];
}
lastModified
колбэк, возвращает UNIX-время последнего изменения → заголовок Last-Modified; браузер шлёт If-Modified-Since
etagSeed
колбэк, возвращает «семя» для ETag (хэшируется); браузер шлёт If-None-Match. Точнее, чем время, но требует вычислить содержимое
cacheControlHeader
Cache-Control (по умолчанию public, max-age=3600)
sessionCacheLimiter
session_cache_limiter(): PHP по умолчанию отправляет Cache-Control: no-store, из-за чего браузеры не кэшируют; '' — не менять, 'public'/'private'/'nocache'

При совпадении фильтр отвечает 304 Not Modified без выполнения действия. Достаточно одного из lastModified/etagSeed; при обоих проверяются оба. Для ETag Yii ставит «weak» ETag, если weakEtag = true (2.0.8).

Что выбрать#

Фрагмент

Дорогой кусок страницы (меню категорий, блок «популярное»), остальное динамическое. variations по языку/роли.

Страница

Публичные страницы без персонализации: лендинги, статьи для гостей. Осторожно с cookie и CSRF-токенами в формах.

HTTP

Всё публичное, что можно отдать браузеру и CDN: лента, API-ответы, картинки. Экономит трафик и время генерации.

Уровни комбинируются: страница закэширована PageCache, а HttpCache перед ним вообще не отдаёт тело, если ETag совпал.

Проверь себя5

Кэша нет — содержимое нужно сгенерировать; при false оно уже выведено из кэша.

Через $this->renderDynamic() — этот код выполняется при каждом показе.

Чтобы для каждого языка/пользователя/устройства был свой экземпляр кэша, иначе один вариант получат все.

Ответ 304 Not Modified без тела; действие контроллера не выполняется.

PHP ставит Cache-Control: no-store через session cache limiter; задайте sessionCacheLimiter => 'public' или ''.

🔌 REST API

38REST API — быстрый старт

ActiveController за пять минут — контроллер, UrlRule с pluralize, parsers/formatters; ресурсы и fields()/extraFields(), ссылки HATEOAS; контроллеры yii\rest\Controller, actions() и checkAccess(); маршрутизация UrlRule с except/extraPatterns; Yii как микрофреймворк.

yii\rest даёт CRUD-API поверх Active Record за десяток строк: ActiveController + одно правило UrlRule — и GET /users, POST /users, PATCH /users/1 работают с пагинацией, сортировкой, валидацией, JSON/XML по Accept, кодами ошибок и HATEOAS-ссылками.

Быстрый старт#

controllers/UserController.phpPHP
namespace app\controllers;

use yii\rest\ActiveController;

class UserController extends ActiveController
{
    public $modelClass = 'app\models\User';
}
config/web.phpPHP
'components' => [
    'request' => [
        'parsers' => ['application/json' => 'yii\web\JsonParser'],   // принимать JSON в теле
    ],
    'urlManager' => [
        'enablePrettyUrl' => true,
        'enableStrictParsing' => true,
        'showScriptName' => false,
        'rules' => [
            ['class' => 'yii\rest\UrlRule', 'controller' => 'user'],
        ],
    ],
],

Готово. Что получилось:

Запрос Действие Результат
GET /usersindexсписок постранично, заголовки X-Pagination-*, Link
HEAD /usersindexтолько заголовки
POST /userscreate201 + созданная модель; при ошибках 422 + список ошибок
GET /users/123viewмодель
PUT, PATCH /users/123updateобновлённая модель
DELETE /users/123delete204
OPTIONS /usersoptionsразрешённые методы в Allow
GET /users?fields=id,email&expand=profileindexвыбор полей и связей
Консоль
curl -i -H "Accept: application/json" "http://localhost/users?page=2&per-page=10&sort=-id"
curl -X POST -H "Content-Type: application/json" -d '{"username":"sam","email":"s@x.io"}' http://localhost/users

Ответ — JSON по умолчанию, XML при Accept: application/xml. Ошибки валидации — HTTP 422 с массивом [{field, message}].

Ресурсы#

Ресурс — объект Arrayable (все модели и AR). Что попадёт в ответ, решают:

PHP
class User extends ActiveRecord
{
    public function fields()
    {
        $fields = parent::fields();               // все атрибуты
        // 🔐 убрать секреты
        unset($fields['auth_key'], $fields['password_hash'], $fields['password_reset_token']);
        $fields['full_name'] = function ($model) { return $model->first_name . ' ' . $model->last_name; };
        $fields['email_short'] = 'email';         // переименовать
        return $fields;
    }

    public function extraFields()
    {
        return ['profile', 'posts'];              // связи — только по ?expand=profile,posts
    }
}

fields() — по умолчанию в ответе; extraFields() — по запросу. Клиент управляет: ?fields=id,email сужает, ?expand=profile добавляет; можно вложенно: expand=posts.comments, fields=posts.title.

Ссылки (HATEOAS)#

PHP
use yii\web\Link;
use yii\web\Linkable;
use yii\helpers\Url;

class User extends ActiveRecord implements Linkable
{
    public function getLinks()
    {
        return [
            Link::REL_SELF => Url::to(['user/view', 'id' => $this->id], true),
            'edit' => Url::to(['user/view', 'id' => $this->id], true),
        ];
    }
}
// в ответе появится: "_links": {"self": {"href": "http://…/users/1"}, ...}

Коллекции#

Действие index возвращает провайдер данных → сериализатор выводит элементы, а пагинацию — в заголовки X-Pagination-Total-Count, X-Pagination-Page-Count, X-Pagination-Current-Page, X-Pagination-Per-Page и Link. Чтобы метаданные попали в тело:

PHP
public $serializer = [
    'class' => 'yii\rest\Serializer',
    'collectionEnvelope' => 'items',   // {"items": [...], "_links": {...}, "_meta": {...}}
];

Контроллеры#

yii\rest\Controller — базовый, без привязки к модели; ActiveController — с готовыми CRUD-действиями. Оба уже подключают фильтры: ContentNegotiator (формат по Accept), VerbFilter, AuthMethod (если настроен), RateLimiter.

PHP
class UserController extends ActiveController
{
    public $modelClass = 'app\models\User';

    public function actions()
    {
        $actions = parent::actions();
        unset($actions['delete'], $actions['create']);                       // убрать
        $actions['index']['prepareDataProvider'] = [$this, 'prepareDataProvider'];   // свой провайдер
        return $actions;
    }

    public function prepareDataProvider()
    {
        return new ActiveDataProvider([
            'query' => User::find()->where(['status' => 1]),
            'pagination' => ['pageSize' => 20],
        ]);
    }

    public function checkAccess($action, $model = null, $params = [])
    {
        if ($action === 'update' || $action === 'delete') {
            if ($model->created_by !== \Yii::$app->user->id) {
                throw new \yii\web\ForbiddenHttpException('Можно менять только свои записи');
            }
        }
    }
}
actions()
index (IndexAction), view, create, update, delete, options; у index — dataFilter (2.0.13) для фильтрации коллекций
checkAccess($action, $model, $params)
вызывается каждым действием; бросайте ForbiddenHttpException
createScenario, updateScenario
сценарии моделей для create/update
serializer
как превращать модели в массив
verbs()
карта действий → HTTP-методов для VerbFilter

Своё действие — обычный метод с return $data; массив/объект сериализуются автоматически:

PHP
public function actionSearch($q)
{
    return new ActiveDataProvider(['query' => User::find()->where(['like', 'username', $q])]);
}

Маршрутизация#

yii\rest\UrlRule создаёт набор правил под контроллер:

PHP
'rules' => [
    ['class' => 'yii\rest\UrlRule', 'controller' => 'user'],
    ['class' => 'yii\rest\UrlRule', 'controller' => ['user', 'post']],         // несколько
    ['class' => 'yii\rest\UrlRule', 'controller' => ['v1/user', 'v1/post']],   // в модуле
    [
        'class' => 'yii\rest\UrlRule',
        'controller' => ['users' => 'user'],       // свой URL-сегмент → контроллер
        'pluralize' => false,                      // не добавлять -s (по умолчанию user → users)
        'prefix' => 'api',                         // /api/users
        'except' => ['delete', 'create'],          // не создавать эти маршруты
        'only' => ['index', 'view'],
        'extraPatterns' => [                       // свои
            'GET search' => 'search',              // GET /users/search → user/search
            'POST {id}/activate' => 'activate',
        ],
        'tokens' => ['{id}' => '<id:\\d[\\d,]*>'], // формат ID: 1 или 1,2,3
    ],
],

Множественное число — через Inflector::pluralize() (person → people); нестандартное задавайте картой. Порядок: маршруты REST обычно выше остальных правил, enableStrictParsing = true отсекает всё, что не описано.

Yii как микрофреймворк#

Не обязательно брать шаблон приложения — можно собрать API из composer require yiisoft/yii2 и одного файла:

index.phpPHP
<?php
require __DIR__ . '/vendor/autoload.php';
require __DIR__ . '/vendor/yiisoft/yii2/Yii.php';

$config = [
    'id' => 'micro-app',
    'basePath' => __DIR__,
    'controllerNamespace' => 'micro\controllers',
    'aliases' => ['@micro' => __DIR__],
    'components' => [
        'db' => ['class' => 'yii\db\Connection', 'dsn' => 'sqlite:@micro/db.sqlite'],
        'urlManager' => [
            'enablePrettyUrl' => true, 'showScriptName' => false, 'enableStrictParsing' => true,
            'rules' => [['class' => 'yii\rest\UrlRule', 'controller' => 'post']],
        ],
    ],
];

(new yii\web\Application($config))->run();

PostController extends ActiveController с $modelClass — и GET /posts уже отвечает. Плюс минимальная модель, миграция через ./yii — по сути тот же фреймворк, но без лишних папок. Хорошо для маленьких сервисов и чтобы понять, как устроено приложение.

Проверь себя5

Парсер запроса: 'parsers' => ['application/json' => 'yii\web\JsonParser'] в компоненте request.

Переопределить fields() модели и убрать чувствительные поля; связи вынести в extraFields().

fields() возвращаются всегда (если клиент не сузил ?fields=), extraFields() — только по ?expand=.

В checkAccess($action, $model) контроллера; бросить ForbiddenHttpException.

Опция prefix => 'api' у yii\rest\UrlRule (или префикс модуля в controller).

39REST API — форматы, аутентификация, версии

ContentNegotiator и Serializer (JSON/XML, envelope, pretty print); аутентификация HttpBasicAuth/HttpBearerAuth/QueryParamAuth/CompositeAuth и findIdentityByAccessToken; ограничение частоты RateLimitInterface; версионирование модулями v1/v2; обработка ошибок и формат ответа; фильтрация коллекций DataFilter.

После быстрого старта остаются вопросы взрослого API: как отвечать в нужном формате, как узнавать клиента без сессии, как не дать одному клиенту положить сервер, как выпускать v2, не ломая v1, и как отдавать ошибки единообразно. Всё это — фильтры и настройки yii\rest.

Формат ответа#

Два шага: ContentNegotiator выбирает формат по Accept/GET-параметру, а Response с нужным formatter сериализует данные из $response->data.

PHP
// yii\rest\Controller уже включает contentNegotiator; настройка:
public function behaviors()
{
    $behaviors = parent::behaviors();
    $behaviors['contentNegotiator']['formats'] = [
        'application/json' => Response::FORMAT_JSON,
        'application/xml' => Response::FORMAT_XML,
    ];
    $behaviors['contentNegotiator']['formatParam'] = '_format';   // ?_format=xml
    return $behaviors;
}

Поддерживаются Accept: application/json; q=1.0, */*; q=0.1. Форматы ответа настраиваются в response.formatters:

PHP
'response' => [
    'formatters' => [
        'json' => [
            'class' => 'yii\web\JsonResponseFormatter',
            'prettyPrint' => YII_DEBUG,
            'encodeOptions' => JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
        ],
        'xml' => ['class' => 'yii\web\XmlResponseFormatter', 'rootTag' => 'response', 'itemTag' => 'item'],
    ],
],

Сериализация данных#

yii\rest\Serializer превращает Arrayable (модели) и DataProviderInterface (коллекции) в массивы с учётом fields/expand:

PHP
public $serializer = [
    'class' => 'yii\rest\Serializer',
    'collectionEnvelope' => 'items',   // коллекция внутри {"items": …, "_links": …, "_meta": …}
    'fieldsParam' => 'fields', 'expandParam' => 'expand',
    'totalCountHeader' => 'X-Pagination-Total-Count', …
];

Свои объекты — реализуйте Arrayable или отдавайте массивы.

Аутентификация#

API обычно без сессий: клиент присылает токен в каждом запросе.

config/web.phpPHP
'user' => [
    'identityClass' => 'app\models\User',
    'enableAutoLogin' => false,
    'enableSession' => false,           // не создавать сессии для API
    'loginUrl' => null,                 // не редиректить — отдавать 401
],
controllers/UserController.phpPHP
use yii\filters\auth\HttpBearerAuth;

public function behaviors()
{
    $behaviors = parent::behaviors();
    $behaviors['authenticator'] = [
        'class' => HttpBearerAuth::class,
        // 'except' => ['login', 'options'],   // OPTIONS должен быть доступен для CORS
    ];
    return $behaviors;
}
models/User.phpPHP
public static function findIdentityByAccessToken($token, $type = null)
{
    return static::findOne(['access_token' => $token, 'status' => self::STATUS_ACTIVE]);
}
HttpBasicAuth
логин/пароль в заголовке Authorization: Basic; по умолчанию логин = токен, но можно auth — колбэк проверки пароля
HttpBearerAuth
Authorization: Bearer <token> — стандарт для API
HttpHeaderAuth
токен в произвольном заголовке (header, pattern), 2.0.14
QueryParamAuth
?access-token=… — только когда заголовки недоступны (утекает в логи)
CompositeAuth
попробовать несколько (authMethods) по очереди
PHP
$behaviors['authenticator'] = [
    'class' => CompositeAuth::class,
    'authMethods' => [HttpBasicAuth::class, HttpBearerAuth::class, QueryParamAuth::class],
];

Неудача — 401 Unauthorized. Аутентификатор вызывает Yii::$app->user->loginByAccessToken($token, $type) → findIdentityByAccessToken(); $type — класс аутентификатора, если токены разных видов.

Ограничение частоты (rate limiting)#

models/User.phpPHP
use yii\filters\RateLimitInterface;

class User extends ActiveRecord implements IdentityInterface, RateLimitInterface
{
    public function getRateLimit($request, $action)
    {
        return [100, 600];        // 100 запросов за 600 секунд
    }

    public function loadAllowance($request, $action)
    {
        return [$this->allowance, $this->allowance_updated_at];   // столбцы в таблице
    }

    public function saveAllowance($request, $action, $allowance, $timestamp)
    {
        $this->allowance = $allowance;
        $this->allowance_updated_at = $timestamp;
        $this->save(false);
    }
}

RateLimiter уже подключён в yii\rest\Controller и включается автоматически, если identity реализует интерфейс. Ответ при превышении — 429 Too Many Requests; в заголовках X-Rate-Limit-Limit, X-Rate-Limit-Remaining, X-Rate-Limit-Reset. Алгоритм — leaky bucket. Хранить allowance можно в кэше вместо БД.

Версионирование#

Версия в URL — самый прозрачный вариант: каждая версия — модуль.

api/
    modules/
        v1/
            controllers/UserController.php
            models/User.php
            Module.php
        v2/
            ...
PHP
'modules' => [
    'v1' => ['class' => 'app\modules\v1\Module'],
    'v2' => ['class' => 'app\modules\v2\Module'],
],
'urlManager' => [
    'rules' => [
        ['class' => 'yii\rest\UrlRule', 'controller' => ['v1/user', 'v1/post']],
        ['class' => 'yii\rest\UrlRule', 'controller' => ['v2/user', 'v2/post']],
    ],
],
// → /v1/users, /v2/users

Общие модели — в common, версионные различия — в fields()/контроллерах модулей. Минорные изменения (новое поле, новый параметр) — совместимы, версия та же; мажорные — новая версия. Альтернатива — версия в заголовке Accept: application/json; version=v2 и разбор в ContentNegotiator/фильтре, но URL проще для клиентов и кэшей.

Обработка ошибок#

Исключения превращаются в ответы автоматически:

Ситуация Код
Успех GET/PUT/PATCH200
POST создал ресурс201
DELETE успешно204
Неверный запрос (JSON не разобран)400
Нет аутентификации401
Аутентифицирован, но нельзя (ForbiddenHttpException)403
Не найдено (NotFoundHttpException)404
Метод не поддерживается (MethodNotAllowedHttpException)405
Формат не поддерживается415
Ошибки валидации (save() вернул false)422
Превышен лимит429
Внутренняя ошибка500

Тело ошибки: {"name": "Not Found", "message": "…", "code": 0, "status": 404} (в debug — плюс type, file, line, стек). Свой формат — событием beforeSend ответа:

PHP
'response' => [
    'class' => 'yii\web\Response',
    'on beforeSend' => function ($event) {
        $response = $event->sender;
        if ($response->data !== null && Yii::$app->request->get('suppress_response_code')) {
            $response->data = ['success' => $response->isSuccessful, 'data' => $response->data];
            $response->statusCode = 200;
        }
    },
],

Фильтрация коллекций#

С 2.0.13 у IndexAction есть dataFilter — безопасный разбор условий из запроса:

PHP
public function actions()
{
    $actions = parent::actions();
    $actions['index']['dataFilter'] = [
        'class' => 'yii\data\ActiveDataFilter',
        'searchModel' => 'app\models\PostSearch',   // модель с rules(): какие атрибуты и какие типы допустимы
    ];
    return $actions;
}
GET /posts?filter[status]=1&filter[title][like]=yii&filter[id][in][]=1&filter[id][in][]=2
GET /posts?filter[or][0][author_id]=1&filter[or][1][created_at][gt]=1600000000

Операторы: and, or, not, lt, gt, lte, gte, eq, neq, in, nin, like. Модель поиска не обязана быть AR — достаточно Model с rules(). Без searchModel — DataFilter с настройкой attributeMap/operatorTypes. Условие превращается в where провайдера; недопустимое — 422.

Вручную то же самое: prepareDataProvider + andFilterWhere по Yii::$app->request->get().

Проверь себя5

Фильтр ContentNegotiator по заголовку Accept (или параметру formatParam) — если формат в списке formats.

findIdentityByAccessToken($token, $type) через Yii::$app->user->loginByAccessToken().

Реализовать RateLimitInterface (getRateLimit, loadAllowance, saveAllowance) в identity-классе — RateLimiter уже подключён.

422 с массивом [{"field": "…", "message": "…"}].

Модуль на версию (v1, v2) с собственными контроллерами и правилами yii\rest\UrlRule с префиксом модуля.

🧰 Инструменты и тесты

40Консольные приложения

Входной скрипт yii и config/console.php, консольные контроллеры и действия с аргументами, опции через options()/optionAliases(), диалог с пользователем (prompt/confirm/select), цветной вывод, коды выхода ExitCode, встроенные команды help/migrate/cache/asset/serve/fixture/message.

Консольное приложение — тот же Yii с той же конфигурацией моделей и компонентов, только вместо HTTP-запроса — аргументы командной строки. Cron-задачи, миграции, импорт, очереди — всё это контроллеры в commands/, запускаемые через ./yii.

Запуск#

Консоль
./yii <route> [--option1=value1 ...] [arg1 arg2 ...]
./yii help                       # список команд
./yii help migrate               # справка по команде и её действиям
./yii migrate/create create_post --interactive=0
./yii hello world                # HelloController::actionIndex('world')

Маршрут controller/action; без действия — defaultAction (по умолчанию index). Входной скрипт yii (в корне) создаёт yii\console\Application с config/console.php:

config/console.phpPHP
return [
    'id' => 'basic-console',
    'basePath' => dirname(__DIR__),
    'bootstrap' => ['log'],
    'controllerNamespace' => 'app\commands',       // где искать контроллеры
    'components' => [
        'cache' => ['class' => 'yii\caching\FileCache'],
        'log' => [...],
        'db' => require __DIR__ . '/db.php',
    ],
    'controllerMap' => [
        'fixture' => ['class' => 'yii\console\controllers\FixtureController', 'namespace' => 'tests\unit\fixtures'],
    ],
    'params' => require __DIR__ . '/params.php',
];

Общее с web-конфигурацией выносите в отдельный файл и подключайте в обоих. YII_DEBUG/YII_ENV задаются во входном скрипте; в консоли YII_DEBUG по умолчанию true, чтобы видеть стек ошибок.

Контроллер#

commands/HelloController.phpPHP
namespace app\commands;

use yii\console\Controller;
use yii\console\ExitCode;
use yii\helpers\Console;

/**
 * Демонстрационная команда (эта строка попадёт в ./yii help).
 */
class HelloController extends Controller
{
    /** @var bool сохранять ли результат — опция --save */
    public $save = false;
    /** @var string опция --format */
    public $format = 'json';

    public function options($actionID)
    {
        return ['save', 'format'];             // какие свойства доступны как опции (для всех действий)
    }

    public function optionAliases()
    {
        return ['s' => 'save', 'f' => 'format'];   // ./yii hello -s -f=xml
    }

    /**
     * Здоровается (описание действия для help).
     * @param string $name кому
     * @param int $times сколько раз
     */
    public function actionIndex($name = 'world', $times = 1)
    {
        for ($i = 0; $i < $times; $i++) {
            $this->stdout("Hello, $name!\n", Console::FG_GREEN);
        }
        return ExitCode::OK;
    }

    public function actionImport(array $ids)      // ./yii hello/import 1,2,3 → [1, 2, 3]
    {
        // ...
        return ExitCode::OK;
    }
}
Аргументы
параметры метода по порядку; необязательные — со значениями по умолчанию; array $x — из строки через запятую
Опции
публичные свойства, перечисленные в options(); --name=value, --flag (bool), -a через optionAliases(); для массивов — --ids=1 --ids=2
Общие опции
--interactive=0, --color, --help есть у всех команд
Код выхода
return ExitCode::OK (0), ExitCode::UNSPECIFIED_ERROR (1), DATAERR, USAGE… — так cron и CI поймут результат
Справка
из PHPDoc класса, действия и @param; ./yii help hello

Диалог и вывод#

PHP
$name = $this->prompt('Ваше имя:', ['required' => true, 'default' => 'anon']);
if ($this->confirm('Продолжить?')) { … }                        // yes/no; при --interactive=0 — всегда да
$choice = $this->select('Формат:', ['json' => 'JSON', 'xml' => 'XML']);

$this->stdout("Готово\n", Console::FG_GREEN, Console::BOLD);
$this->stderr("Ошибка\n", Console::FG_RED);
echo $this->ansiFormat('внимание', Console::FG_YELLOW);
Console::startProgress(0, $total);  Console::updateProgress($done, $total);  Console::endProgress();
// цвета включаются, если терминал их поддерживает (--color)
$this->isColorEnabled();

Не используйте exit() — верните код; не используйте echo для ошибок — stderr.

Встроенные команды#

Команда Назначение
helpсправка по всем командам
migrateмиграции: create, up, down, redo, fresh, history, new, mark
cacheflush, flush-all, flush-schema
assetсборка и сжатие ассетов (asset/template, asset config.php bundles.php)
fixtureзагрузка тестовых данных (fixture/load, fixture/unload)
messageизвлечение строк для перевода (message/config, message)
serveвстроенный сервер разработки: ./yii serve --port=8080
helloпример из шаблона

Команды расширений (например, queue) добавляются в controllerMap или через bootstrap.

Особенности консольного окружения#

  • Нет request в web-смысле: Yii::$app->request — yii\console\Request с params; нет session, user (компоненты web).
  • Url::to() без hostInfo не построит абсолютный URL — задайте 'urlManager' => ['hostInfo' => 'https://example.com', 'baseUrl' => '/'] в консольной конфигурации (нужно для писем из cron).
  • Псевдоним @web/@webroot не определены — задайте вручную, если нужны.
  • Ошибки идут в stderr и в log; errorHandler — yii\console\ErrorHandler.
  • Длинные задачи: следите за памятью (batch() в запросах, gc_collect_cycles()), логи с flushInterval.

Cron#

Консоль
*/5 * * * * cd /var/www/app && ./yii cleanup/temp --interactive=0 >> runtime/logs/cron.log 2>&1

Запускайте от пользователя приложения, чтобы файлы в runtime/ создавались с правильными правами.

Проверь себя5

Объявить публичное свойство $dryRun, вернуть 'dryRun' из options(); вызывать ./yii cmd --dryRun=1 (или --dry-run — оба варианта работают с 2.0.x).

Объявить параметр действия array $ids и передать 1,2,3.

Ненулевой код: ExitCode::UNSPECIFIED_ERROR или другой из yii\console\ExitCode.

В консоли нет HTTP-запроса; задайте hostInfo и baseUrl у urlManager в консольной конфигурации.

Опция --interactive=0.

41Тестирование

Codeception в шаблонах Yii — модульные, функциональные и приёмочные тесты; настройка окружения и БД для тестов; фикстуры ActiveFixture с dataFile и зависимостями, загрузка через _fixtures() и команду fixture; что тестировать и как.

Yii дружит с Codeception, а шаблоны приложений уже содержат папку tests/ с конфигурацией. Три уровня: unit — класс изолированно, functional — запрос к приложению без браузера, acceptance — реальный браузер через Selenium/WebDriver. Плюс фикстуры — воспроизводимые данные в БД.

Три вида тестов#

Вид Что проверяет Скорость Инструмент
Модульные (unit)метод/класс: модель, хелпер, сервисбыстроCodeception unit / PHPUnit
Функциональныеконтроллер + модель + представление, HTTP без веб-серверасреднемодуль Yii2 Codeception
Приёмочные (acceptance)сценарий пользователя в браузере, JSмедленноWebDriver / PhpBrowser

Пирамида: много unit, меньше функциональных, единицы приёмочных.

Окружение#

Консоль
# уже есть в шаблонах
composer require --dev codeception/codeception codeception/module-asserts codeception/module-yii2
cp .env.example .env    # (advanced) или config/test_db.php
./yii_test migrate      # (advanced: yii_test — консоль с тестовой конфигурацией)
vendor/bin/codecept run                     # все тесты
vendor/bin/codecept run unit                # только модульные
vendor/bin/codecept run functional LoginCest
vendor/bin/codecept run --coverage --coverage-html   # покрытие (xdebug/pcov)
tests/
    unit/          # *Test.php — PHPUnit-стиль
    functional/    # *Cest.php
    acceptance/    # *Cest.php
    _data/         # фикстуры и данные
    _support/      # хелперы, UnitTester/FunctionalTester
    unit.suite.yml, functional.suite.yml, acceptance.suite.yml
codeception.yml

Тестовая конфигурация приложения — config/test.php (basic) с отдельной БД yii2basic_test (config/test_db.php); входной скрипт web/index-test.php с YII_ENV = 'test'. В acceptance-тестах приложение должно быть доступно по URL (./yii serve или веб-сервер) с index-test.php.

Модульные тесты#

tests/unit/models/UserTest.phpPHP
namespace tests\unit\models;

use app\models\User;
use app\tests\fixtures\UserFixture;

class UserTest extends \Codeception\Test\Unit
{
    /** @var \UnitTester */
    protected $tester;

    public function _fixtures()
    {
        return ['users' => UserFixture::class];      // загружаются перед каждым тестом
    }

    public function testFindUserById()
    {
        verify($user = User::findIdentity(100))->notEmpty();
        verify($user->username)->equals('admin');
        verify(User::findIdentity(999))->empty();
    }

    public function testValidateWrongPassword()
    {
        $user = $this->tester->grabFixture('users', 'admin');
        verify($user->validatePassword('wrong'))->false();
    }
}

Модели тестируют «в лоб»: создать, задать атрибуты, validate(), проверить errors. Сервисы с зависимостями — через конструкторное внедрение и заглушки ($this->make(), Stub::make()) или подмену компонентов Yii::$app->set('mailer', $stub).

Функциональные тесты#

tests/functional/LoginCest.phpPHP
class LoginCest
{
    public function _before(\FunctionalTester $I)
    {
        $I->amOnRoute('site/login');
    }

    public function loginWithEmptyCredentials(\FunctionalTester $I)
    {
        $I->submitForm('#login-form', []);
        $I->expectTo('see validations errors');
        $I->see('Username cannot be blank.');
    }

    public function loginSuccessfully(\FunctionalTester $I)
    {
        $I->submitForm('#login-form', ['LoginForm[username]' => 'admin', 'LoginForm[password]' => 'admin']);
        $I->see('Logout (admin)');
        $I->dontSeeElement('form#login-form');
    }

    public function checkAsLoggedUser(\FunctionalTester $I)
    {
        $I->amLoggedInAs(100);                          // модуль Yii2: войти без формы
        $I->amOnPage('/profile');
        $I->seeResponseCodeIs(200);
        $I->seeRecord('app\models\User', ['id' => 100]); // проверка БД
        $I->sendAjaxPostRequest('/api/ping', []);
    }
}

Модуль Yii2 в functional.suite.yml (configFile: config/test.php) даёт amOnRoute, amLoggedInAs, seeRecord, grabRecord, seeEmailIsSent, доступ к Yii::$app и транзакцию вокруг каждого теста (cleanup: true).

Приёмочные тесты#

tests/acceptance/HomeCest.phpPHP
class HomeCest
{
    public function ensureThatHomePageWorks(\AcceptanceTester $I)
    {
        $I->amOnPage(Yii::$app->homeUrl);
        $I->see('My Company');
        $I->seeLink('About');
        $I->click('About');
        $I->wait(2);                       // JS/анимации
        $I->see('This is the About page.');
    }
}

acceptance.suite.yml: PhpBrowser (быстрый, без JS) или WebDriver (url, browser: chrome) — нужен запущенный Selenium/ChromeDriver и сервер приложения. Здесь проверяют только ключевые сценарии: регистрация, оплата, поиск.

Фикстуры#

Фикстура приводит БД (или другое состояние) к известному виду перед тестом и убирает после.

tests/fixtures/UserFixture.phpPHP
namespace app\tests\fixtures;

use yii\test\ActiveFixture;

class UserFixture extends ActiveFixture
{
    public $modelClass = 'app\models\User';        // или $tableName
    public $dataFile = '@tests/_data/user.php';    // по умолчанию — tests/fixtures/data/user.php
    public $depends = ['app\tests\fixtures\ProfileFixture'];   // сначала загрузятся зависимости
}
tests/_data/user.phpPHP
return [
    'admin' => [
        'username' => 'admin', 'email' => 'admin@example.com',
        'auth_key' => 'key', 'password_hash' => '$2y$13$…',
    ],
    'user1' => [
        'username' => 'user1', 'email' => 'user1@example.com',
        'auth_key' => 'key2', 'password_hash' => '…',
    ],
];

Ключ строки (admin) — псевдоним записи; поле id подставится автоматически. ActiveFixture::load() очищает таблицу и вставляет данные; unload() — очищает. Своя логика — наследовать yii\test\Fixture (load/unload) или DbFixture.

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

PHP
// в тесте
public function _fixtures()
{
    return [
        'users' => ['class' => UserFixture::class, 'dataFile' => '@tests/_data/user.php'],
        'profiles' => ProfileFixture::class,
    ];
}
$user = $this->tester->grabFixture('users', 'admin');   // модель ActiveRecord
$this->tester->grabFixture('users');                     // весь объект фикстуры; ->data — исходные массивы

// в PHPUnit без Codeception — FixtureTrait: fixtures() + loadFixtures()/unloadFixtures(), $this->users('admin')

Консольная загрузка#

Консоль
# загрузить фикстуру User из namespace (по умолчанию tests\unit\fixtures)
./yii fixture/load User
./yii fixture/load "*"                  # все
./yii fixture/load User Profile --namespace=app\\tests\\fixtures
./yii fixture/unload User
./yii fixture/load "*" -except=User
./yii fixture/load User --globalFixtures=InitDb      # общие (InitDbFixture: отключает проверку FK на время)

Фикстуры — не только БД: Fixture может готовить файлы, кэш, внешние сервисы. Данные для фикстур можно генерировать с Faker через расширение yii2-faker (./yii fixture/generate).

Что покрывать в первую очередь#

Модели

правила валидации, бизнес-методы, связи: дёшево и ловит большинство ошибок

Формы и сценарии

какие атрибуты безопасны, что запрещено

Критичные маршруты

вход, регистрация, оплата, API — функциональными тестами

Права

гость не видит, автор может, админ может всё

Регрессии

каждый пойманный баг превращается в тест

Проверь себя5

Функциональный отправляет запрос в приложение напрямую (без браузера и сервера), приёмочный — через реальный браузер и HTTP.

$I->amLoggedInAs($idOrIdentity) — метод модуля Yii2.

load() только вставляет строки из dataFile. Таблицу опустошает unload() (через resetTable()), а FixtureTrait вызывает выгрузку перед загрузкой — поэтому со стороны кажется, будто чистит load().

Чтобы получать конкретную запись в тесте: $this->tester->grabFixture('users', 'admin').

Свойством depends — зависимости загружаются раньше.

42Gii, отладчик и другие инструменты

Генератор кода Gii — установка, allowedIPs, генераторы Model/CRUD/Controller/Form/Module/Extension, консольный режим и свои шаблоны; панель отладки yii2-debug — панели, профилирование, allowedIPs; apidoc, встроенный сервер и полезные команды.

Два расширения, которые в dev-режиме подключены в каждом шаблоне: Gii генерирует модели и CRUD по таблице, Debug показывает запросы, логи, время и память прямо под страницей. Плюс apidoc для документации по PHPDoc и мелочи вроде ./yii serve.

Gii#

Подключение#

config/web.phpPHP
if (YII_ENV_DEV) {
    $config['bootstrap'][] = 'gii';
    $config['modules']['gii'] = [
        'class' => 'yii\gii\Module',
        'allowedIPs' => ['127.0.0.1', '::1', '192.168.0.*'],   // по умолчанию только localhost
        // 'generators' => [
        //     'crud' => ['class' => 'yii\gii\generators\crud\Generator', 'templates' => ['my' => '@app/gii/crud']],
        // ],
    ];
}

composer require --dev yiisoft/yii2-gii (в шаблонах уже есть). Открыть: /index.php?r=gii или /gii с красивыми URL. Только dev — генератор пишет файлы в проект.

Генераторы#

Model
класс AR по таблице: tableName(), rules() по типам столбцов, attributeLabels(), связи по внешним ключам (generateRelations), опционально Query-класс и {{%…}}
CRUD
контроллер + Search-модель + представления index/view/create/update/_form с GridView/DetailView/ActiveForm; опции: виджет списка (GridView/ListView), Pjax, i18n, базовый класс контроллера
Controller
контроллер с действиями и пустыми представлениями
Form
представление формы по существующей модели
Module
каркас модуля: Module.php, controllers/DefaultController.php, views/default/index.php
Extension
каркас расширения с composer.json

Каждый генератор показывает превью файлов и diff с существующими — можно выбрать, что перезаписать. Сгенерированный код — отправная точка: правила и подписи стоит проверить.

Консольный режим#

Консоль
./yii help gii
./yii gii/model --tableName=post --modelClass=Post --ns=app\\models --generateRelations=all
./yii gii/crud --modelClass=app\\models\\Post --searchModelClass=app\\models\\PostSearch \
    --controllerClass=app\\controllers\\PostController --viewPath=@app/views/post --enablePjax=1
./yii gii/controller --controllerClass=app\\controllers\\ReportController --actions=index,export
./yii gii/module --moduleID=admin --moduleClass=app\\modules\\admin\\Module

Для консоли модуль gii подключают в config/console.php. Полезно в CI и скриптах инициализации.

Свои шаблоны и генераторы#

Скопировать vendor/yiisoft/yii2-gii/src/generators/crud/default в @app/gii/crud, поправить и указать в templates генератора — в форме появится выбор шаблона. Свой генератор — класс extends yii\gii\Generator с формой (form.php), generate() и templates; регистрируется в generators модуля.

Панель отладки (yii2-debug)#

config/web.phpPHP
if (YII_ENV_DEV) {
    $config['bootstrap'][] = 'debug';
    $config['modules']['debug'] = [
        'class' => 'yii\debug\Module',
        'allowedIPs' => ['127.0.0.1', '::1'],
        // 'panels' => ['queue' => ...], 'historySize' => 50, 'dataPath' => '@runtime/debug',
    ];
}

Внизу страницы — тулбар; клик открывает панели по последним запросам (/debug):

Панель Что показывает
Configверсии PHP/Yii, конфигурация приложения, phpinfo
Requestпараметры, заголовки, сессия, cookies, тело ответа
Logsвсё из Yii::info/warning/error/debug с категориями и фильтром
ProfilingYii::beginProfile()/endProfile() — время и память по блокам
Databaseвсе SQL-запросы с временем; дубли; кнопка EXPLAIN
Asset Bundlesкакие бандлы и файлы подключены
Mailотправленные письма (при useFileTransport)
Useridentity, роли/разрешения RBAC, переключение пользователя
Routerправила urlManager, какое сработало
Events, Timeline, Dumpсобытия за запрос, шкала времени, переменные Yii::debug()
PHP
Yii::beginProfile('import');
// ...
Yii::endProfile('import');
Yii::debug($data, 'my.category');    // видно в панели Logs (и Dump)

AJAX-запросы тоже попадают в историю. В production модуль не подключают: даже с allowedIPs он раскрывает конфигурацию.

Другие инструменты#

./yii serve --port=8080 --docroot=web
встроенный PHP-сервер для разработки
./yii help
все команды; ./yii help <cmd> — параметры
vendor/bin/apidoc api src docs/api
HTML-документация по PHPDoc (yiisoft/yii2-apidoc); apidoc guide — рендер markdown-руководств в стиле yiiframework.com
./yii cache/flush-all, ./yii asset
кэш и сборка ассетов
Yii::getVersion(), phpinfo() в панели Config
версии
composer outdated, composer show yiisoft/*
версии пакетов
Проверь себя5

allowedIPs по умолчанию ['127.0.0.1', '::1']; для других адресов их нужно добавить явно, и только в dev.

Поисковую модель (PostSearch) и представления index, view, create, update, _form.

./yii gii/model --tableName=post --modelClass=Post (модуль gii должен быть в консольной конфигурации).

Панель Database отладчика; дубликаты и EXPLAIN — там же.

Обернуть в Yii::beginProfile('name') / Yii::endProfile('name').

🧩 Специальные темы

43Интернационализация

language и sourceLanguage, Yii::t() с параметрами, множественным числом, select, number и date (ICU MessageFormat), источники переводов PhpMessageSource/DbMessageSource/GettextMessageSource, извлечение сообщений командой message, переводы представлений и модулей, локаль форматтера.

I18n в Yii — это три вещи: язык приложения (Yii::$app->language), перевод сообщений через Yii::t() с форматированием ICU и локализация вывода дат/чисел через formatter. Строки пишутся на исходном языке (обычно английском), переводы хранятся в файлах или БД по категориям.

Языки#

PHP
'sourceLanguage' => 'en-US',     // на каком языке написаны строки в коде (по умолчанию en-US)
'language' => 'ru-RU',           // язык вывода; можно менять во время выполнения
PHP
// например, по настройке пользователя или заголовку Accept-Language
Yii::$app->language = 'ru-RU';
Yii::$app->language = Yii::$app->request->getPreferredLanguage(['en-US', 'ru-RU', 'de']);

Код языка — IETF: en, en-US, ru-RU, zh-Hans-CN. Если для ru-RU перевода нет, будет искаться ru.

Yii::t()#

PHP
echo Yii::t('app', 'This is a string to translate!');
echo Yii::t('app', 'Hello, {username}!', ['username' => $username]);
echo Yii::t('app', 'Price: {0}', [$price]);                             // позиционные — {0}, {1}
echo Yii::t('app', 'Balance: {0, number, currency}', [$amount]);        // формат числа через ICU
echo Yii::t('app', 'Today is {0, date, short}', [time()]);
echo Yii::t('app', 'Hello', [], 'de');                                  // явный язык

Категория (app) — группа сообщений; yii зарезервирована для фреймворка, расширения используют свои (yii/bootstrap...). Если строка не переведена, вернётся исходная (и сработает событие missingTranslation).

Множественное число#

PHP
echo Yii::t('app', 'There {n, plural, =0{are no cats} =1{is one cat} other{are # cats}}!', ['n' => $n]);

Формы для русского перевода — one, few, many, other (правила CLDR):

PHP
'There {n, plural, =0{are no cats} =1{is one cat} other{are # cats}}!' =>
    'На диване {n, plural, =0{нет кошек} one{# кошка} few{# кошки} many{# кошек} other{# кошки}}!',

# — число, =0 — точное значение. Также {n, plural, offset:1 …} и {n, selectordinal, one{#st} two{#nd} few{#rd} other{#th}}.

Выбор по значению#

PHP
echo Yii::t('app', '{name} is {gender, select, female{she} male{he} other{it}} and loves Yii!', [
    'name' => 'Alexander',
    'gender' => 'male',
]);

Числа, даты, порядковые#

PHP
Yii::t('app', '{n, number}', ['n' => 1234.5]);                 // 1 234,5 (ru)
Yii::t('app', '{n, number, percent}', ['n' => 0.42]);
Yii::t('app', '{n, number, #,##0.00}', ['n' => 1234.5]);        // свой паттерн
Yii::t('app', '{d, date, long}', ['d' => time()]);              // short | medium | long | full | паттерн
Yii::t('app', '{t, time, short}', ['t' => time()]);
Yii::t('app', '{n, spellout}', ['n' => 42]);                    // forty-two
Yii::t('app', '{n, ordinal}', ['n' => 42]);                     // 42nd

Всё это MessageFormatter расширения intl; без intl доступны только простые подстановки, plural и select.

Источники переводов#

config/web.phpPHP
'components' => [
    'i18n' => [
        'translations' => [
            'app*' => [                                     // категории app, app/error, app/mail…
                'class' => 'yii\i18n\PhpMessageSource',
                'basePath' => '@app/messages',              // messages/ru-RU/app.php
                'sourceLanguage' => 'en-US',
                'fileMap' => [
                    'app' => 'app.php',
                    'app/error' => 'error.php',
                ],
                // 'forceTranslation' => true,   // переводить, даже если language == sourceLanguage
                // 'on missingTranslation' => [TranslationEventHandler::class, 'handleMissingTranslation'],
            ],
            '*' => ['class' => 'yii\i18n\PhpMessageSource'],   // остальное — по умолчанию @app/messages
        ],
    ],
],
messages/ru-RU/app.phpPHP
return [
    'This is a string to translate!' => 'Это строка для перевода!',
    'Hello, {username}!' => 'Привет, {username}!',
    'not translated yet' => '',        // пустая — вернётся исходная
];
PhpMessageSource
PHP-массивы basePath/<язык>/<категория>.php; самый быстрый и простой
DbMessageSource
таблицы source_message и message (миграция @yii/i18n/migrations); переводы редактируются в админке
GettextMessageSource
файлы .po/.mo, инструменты переводчиков

Ключ app* — маска по префиксу. Переводы фреймворка (yii) уже есть на десятках языков — категорию yii настраивать не нужно.

Извлечение сообщений#

Консоль
./yii message/config-template @app/messages/config.php    # шаблон конфигурации
./yii message/config --languages=ru-RU,de --messagePath=@app/messages @app/messages/config.php
./yii message @app/messages/config.php                    # просканировать код, обновить файлы переводов

Команда находит все вызовы Yii::t() (и \Yii::t, $this->t в шаблонах — настраивается translator), создаёт файлы для каждого языка, помечает удалённые строки @@…@@, сортирует. Для БД — 'format' => 'db', для gettext — 'po'.

Переводы в разных местах#

PHP
// модели: подписи и ошибки
public function attributeLabels() { return ['username' => Yii::t('app', 'Username')]; }
['username', 'required', 'message' => Yii::t('app', '{attribute} is required')],

// представления: целые файлы — views/site/ru-RU/about.php выберется автоматически для language = ru-RU
// (устарело с 2.0.x, лучше Yii::t внутри представлений)

// виджеты и модули — своя категория и регистрация переводов
class Module extends \yii\base\Module
{
    public function init()
    {
        parent::init();
        $this->registerTranslations();
    }

    public function registerTranslations()
    {
        Yii::$app->i18n->translations['modules/users/*'] = [
            'class' => 'yii\i18n\PhpMessageSource',
            'sourceLanguage' => 'en-US',
            'basePath' => '@app/modules/users/messages',
            'fileMap' => ['modules/users/validation' => 'validation.php', 'modules/users/form' => 'form.php'],
        ];
    }

    public static function t($category, $message, $params = [], $language = null)
    {
        return Yii::t('modules/users/' . $category, $message, $params, $language);
    }
}
// Module::t('form', 'Save')

Локаль форматтера и прочее#

  • Yii::$app->formatter->locale по умолчанию = language — даты и числа локализуются автоматически.
  • Yii::$app->language влияет на Html::tag('html', …, ['lang' => …]) — задавайте lang в layout из Yii::$app->language.
  • RTL-языки: Yii::$app->formatter не решает вёрстку — используйте класс dir="rtl" в layout по языку.
  • URL с языком (/ru/site/about) — свой UrlManager или правило с параметром <lang:(ru|en)> и установка языка в beforeAction/bootstrap; готовые расширения: codemix/yii2-localeurls.
Проверь себя5

Исходную строку Hello и вызовет событие missingTranslation у источника.

one, few, many (и other); =0/=1 — для точных значений.

@app/messages/ru-RU/app.php.

./yii message/config для конфигурации, затем ./yii message config.php.

Зарегистрировать источник в Yii::$app->i18n->translations под своей категорией (обычно в init() модуля) и использовать эту категорию в Yii::t().

44Отправка почты

Компонент mailer (yii2-symfonymailer / swiftmailer) — compose(), setFrom/setTo/setSubject, текст и HTML, представления писем и layout, вложения и встроенные изображения, массовая отправка sendMultiple, транспорт SMTP, файловый транспорт для разработки и тестов.

Yii::$app->mailer — единый интерфейс к почтовой библиотеке. В новых шаблонах это yiisoft/yii2-symfonymailer (Symfony Mailer), в старых — yii2-swiftmailer. Письма собираются из представлений с layout, вложениями и картинками, а в разработке падают в файлы вместо отправки.

Настройка#

config/web.phpPHP
'components' => [
    'mailer' => [
        'class' => \yii\symfonymailer\Mailer::class,
        'viewPath' => '@app/mail',                 // представления писем
        'useFileTransport' => YII_ENV_DEV,         // в dev — писать в @runtime/mail вместо отправки
        'transport' => [
            'scheme' => 'smtps',                   // smtp | smtps | sendmail | native
            'host' => 'smtp.example.com',
            'port' => 465,
            'username' => 'user', 'password' => 'secret',
            'dsn' => 'smtp://user:pass@smtp.example.com:587',   // альтернатива — одной строкой
        ],
        'messageConfig' => [
            'from' => ['noreply@example.com' => 'My Site'],   // отправитель по умолчанию
            'charset' => 'UTF-8',
        ],
    ],
],

Для swiftmailer — yii\swiftmailer\Mailer с transport => ['class' => 'Swift_SmtpTransport', 'host' => …, 'encryption' => 'tls']. Пароли — вне репозитория.

Отправка#

PHP
Yii::$app->mailer->compose()
    ->setFrom('from@domain.com')
    ->setTo('to@domain.com')                          // строка, массив, ['email' => 'Имя']
    ->setCc('cc@domain.com')->setBcc(['a@x.io', 'b@x.io'])
    ->setReplyTo('support@domain.com')
    ->setSubject('Тема')
    ->setTextBody('Текстовая версия')
    ->setHtmlBody('<b>HTML-версия</b>')
    ->send();                                          // true/false

С представлением:

PHP
Yii::$app->mailer->compose('passwordReset', ['user' => $user])     // mail/passwordReset.php + layouts/html.php
    ->setTo($user->email)
    ->setSubject('Восстановление пароля')
    ->send();

// разные шаблоны для HTML и текста
Yii::$app->mailer->compose(['html' => 'passwordReset-html', 'text' => 'passwordReset-text'], ['user' => $user])
mail/passwordReset.phpPHP
<?php
use yii\helpers\Html;
use yii\helpers\Url;

// абсолютный URL!
$resetLink = Url::to(['site/reset-password', 'token' => $user->password_reset_token], true);
?>
<p>Здравствуйте, <?= Html::encode($user->username) ?>!</p>
<p>Для сброса пароля перейдите по ссылке: <?= Html::a(Html::encode($resetLink), $resetLink) ?></p>

$message (объект письма) доступен в представлении — можно $message->setSubject() прямо там. Layout — mail/layouts/html.php и text.php (htmlLayout/textLayout); false — без layout. Внутри layout — $content и $this->head()/beginBody()/endBody(), как в обычных.

Вложения и картинки#

PHP
$message = Yii::$app->mailer->compose();
$message->attach('/path/to/report.pdf');
$message->attach('/path/to/file', ['fileName' => 'отчёт.pdf', 'contentType' => 'application/pdf']);
$message->attachContent($csvString, ['fileName' => 'data.csv', 'contentType' => 'text/csv']);

// встроенное изображение: сначала embed, потом cid в HTML
$imgSrc = $message->embed('/path/to/logo.png');
$message->setHtmlBody('<img src="' . $imgSrc . '"> Привет!');
// или из представления: $message->embed(…) внутри mail/…php

Массовая отправка#

PHP
$messages = [];
foreach ($users as $user) {
    $messages[] = Yii::$app->mailer->compose('newsletter', ['user' => $user])
        ->setTo($user->email)
        ->setSubject('Новости');
}
$sent = Yii::$app->mailer->sendMultiple($messages);   // число отправленных; одно соединение SMTP

Большие рассылки — из консольной команды или очереди (yii2-queue), не из web-запроса. В консоли задайте urlManager.hostInfo, чтобы Url::to(…, true) строил ссылки.

Разработка и тесты#

PHP
'useFileTransport' => true,
'fileTransportPath' => '@runtime/mail',      // по умолчанию
// 'fileTransportCallback' => function ($mailer, $message) { return 'message-' . time() . '.eml'; },

Письма сохраняются как .eml — открываются почтовым клиентом или панелью Mail в отладчике. В функциональных тестах модуль Yii2 Codeception: $I->seeEmailIsSent(), $I->grabLastSentEmail(), $I->grabSentEmails().

События и расширение#

  • mailer генерирует beforeSend ($event->isValid = false — отменить) и afterSend.
  • Свой mailer — наследовать yii\mail\BaseMailer (sendMessage()) и yii\mail\BaseMessage; так работают расширения для Mailgun, SES, SendGrid.
  • Шаблоны с Twig/Smarty — те же рендереры представлений.
Проверь себя5

useFileTransport => true — письма пишутся в @runtime/mail как .eml; видны и в панели Mail отладчика.

В viewPath (по умолчанию @app/mail): welcome.php + layout layouts/html.php.

$src = $message->embed($path) и <img src="…"> с полученным cid.

Использован относительный URL; нужен Url::to([...], true) (в консоли — с настроенным hostInfo).

Одно соединение с SMTP на все письма и возврат числа отправленных.

45Производительность

Чек-лист оптимизации — production-режим и opcache, кэш схемы и запросов, оптимизация автозагрузчика Composer, сборка ассетов, сессии в БД/кэше, тяжёлые выборки (asArray, batch, with), логирование и профилирование, где искать узкие места.

Yii быстр по умолчанию, но большинство «медленных сайтов» — это выключенный кэш, включённый debug и N+1 запросов. Ниже — порядок действий: сначала бесплатные переключатели, потом кэширование, потом профилирование и точечные правки.

1. Бесплатные переключатели#

  1. Production-режим: в index.php YII_DEBUG = false, YII_ENV = 'prod' (или не задавать — умолчания именно такие). Отладчик и Gii не подключать.
  2. OPcache: opcache.enable=1, opcache.validate_timestamps=0 (сбрасывать при деплое), opcache.memory_consumption=256. Самый большой выигрыш.
  3. Автозагрузчик: composer install --no-dev --optimize-autoloader (или dump-autoload -o --classmap-authoritative).
  4. Кэш схемы БД: 'enableSchemaCache' => true, 'schemaCacheDuration' => 3600 в db — иначе AR читает структуру таблиц каждым запросом. Сбрасывать ./yii cache/flush-schema после миграций.
  5. Логи: traceLevel => 0 в production (трассировка стека дорогая), flushInterval/exportInterval побольше, только нужные levels/categories.
  6. Xdebug выключен на production.

2. Кэширование#

  • Данные — getOrSet() с зависимостями (кэширование данных); хранилище — APCu для одного сервера, Redis/Memcached для нескольких.
  • Запросы — Query::cache(), Connection::cache(); схема — выше.
  • Фрагменты/страницы/HTTP — кэширование вывода.
  • RBAC: authManager.cache, URL-правила: urlManager.cache (по умолчанию используют компонент cache).
  • Переводы PhpMessageSource кэшируются opcache сами; DbMessageSource — cachingDuration.

3. Сессии#

Файловые сессии на одном сервере — нормально; при нескольких серверах или тысячах пользователей:

PHP
// или CacheSession с Redis/Memcached
'session' => ['class' => 'yii\web\DbSession', 'sessionTable' => 'session'],

Не открывать сессию, когда не нужно (например, для API: enableSession => false); сборка мусора — gcProbability. Кэшированная сессия быстрее, но теряется при рестарте кэша.

4. База данных#

  • Индексы по столбцам из where, join, orderBy; смотрите EXPLAIN в панели Database отладчика.
  • N+1: with() для связей в списках; joinWith('rel', false) + with(), если нужен фильтр по связи.
  • asArray() для больших выборок только на чтение; select() только нужных столбцов; batch()/each() для экспорта.
  • count() дорог на больших таблицах — кэшируйте или храните счётчик (updateCounters).
  • Массовые операции — updateAll()/deleteAll()/batchInsert() вместо циклов save().
  • DAO там, где AR избыточен (агрегаты, отчёты).
  • Реплики: slaves в соединении — чтение с реплик, запись в мастер.
  • Постоянные соединения (attributes => [PDO::ATTR_PERSISTENT => true]) — с осторожностью.

5. Ассеты и фронтенд#

Консоль
./yii asset/template assets.php      # шаблон конфигурации сборки
./yii asset assets.php config/assets-prod.php

Команда объединяет и минифицирует CSS/JS бандлов (нужны closure-compiler/yuicompressor или свои jsCompressor/cssCompressor); результат подключается через assetManager.bundles. Плюс: appendTimestamp => true (кэш браузера), linkAssets => true (симлинки вместо копирования), отдача статики веб-сервером с gzip/brotli и заголовками кэша, CDN для jquery/bootstrap через переопределение бандлов.

6. Код приложения#

  • Не вызывайте тяжёлое в init()/конструкторах компонентов, которые создаются на каждом запросе; ленивые компоненты создаются только при обращении — не тяните их в bootstrap без нужды.
  • Html::encode дешевле HtmlPurifier::process — чистите HTML при сохранении, а не при показе (или кэшируйте).
  • Yii::t() с intl быстрее без — но и файлы переводов не должны быть гигантскими.
  • Widgets/render в цикле — заменить на один ListView/строковую сборку.
  • Yii::$app->formatter для тысяч ячеек — предварительно вычислить формат один раз.
  • Уменьшайте traceLevel, отключайте profiling в production.

7. Измерять, а не гадать#

PHP
Yii::beginProfile('block');
// …
Yii::endProfile('block');

Панель Profiling и Database в отладчике показывают время по блокам и запросам. На production — yii\log\FileTarget с levels => ['profile'] на короткое время, APM (Blackfire, Xhprof, New Relic), медленные запросы в логах СУБД. ab/wrk/k6 для нагрузочного теста до и после изменений.

Чек-лист перед выкладкой#

YII_DEBUG=false

, debug/gii выключены

OPcache

включён, validate_timestamps=0

composer

--no-dev -o

enableSchemaCache

и cache не DummyCache/FileCache на нескольких серверах

Сессии

в БД/Redis при нескольких серверах

N+1

проверен в панели Database на ключевых страницах

Ассеты

собраны, appendTimestamp, gzip на сервере

Логи

только error/warning, traceLevel=0

Проверь себя5

enableSchemaCache => true — иначе схема таблицы читается при каждом запросе.

Включаются трассировка, профилирование и подробные логи, что заметно замедляет каждый запрос.

Открыть панель Database отладчика: десятки одинаковых SELECT … WHERE id = ? — признак; лечится with().

Объединяет и минифицирует CSS/JS бандлов по конфигурации в единые файлы для production.

При нескольких веб-серверах (общий доступ) или очень большом числе сессий.

46Хелперы

Статические классы yii\helpers — ArrayHelper (getValue, map, index, merge, multisort, toArray), Html (теги, ссылки, формы, active-поля, CSS-классы), Json (encode/decode, JsExpression, htmlEncode), Url (to, toRoute, current, canonical, remember/previous), а также Inflector, StringHelper, FileHelper, VarDumper, Console, Markdown.

Хелперы — статические классы для рутины: безопасно достать значение из массива, собрать тег с экранированием, построить URL по маршруту, закодировать JSON. Расширяются наследованием: class Html extends BaseHtml — каждый хелпер состоит из BaseXxx с логикой и тонкого Xxx, который можно подменить.

ArrayHelper#

PHP
use yii\helpers\ArrayHelper;

// достать значение — из массива или объекта, с вложенностью и значением по умолчанию
$name = ArrayHelper::getValue($user, 'name', 'anon');
// $user['address']['street'] или $user->address->street
$street = ArrayHelper::getValue($user, 'address.street');
$val = ArrayHelper::getValue($arr, ['x', 'y']);                     // ключ с точкой — массивом
$fullName = ArrayHelper::getValue($user, function ($user, $default) { return $user->first . ' ' . $user->last; });
ArrayHelper::setValue($array, 'key.subkey', $value);
ArrayHelper::keyExists('Name', $array, false);                      // регистронезависимо
ArrayHelper::remove($array, 'type', 'default');                     // достать и удалить

// столбцы, карты, индексы
ArrayHelper::getColumn($posts, 'id');                               // [1, 2, 3]
ArrayHelper::getColumn($posts, function ($p) { return $p->title; });
ArrayHelper::map($countries, 'code', 'name');                       // ['RU' => 'Россия'] — для dropDownList
ArrayHelper::map($countries, 'code', 'name', 'continent');          // с группировкой
ArrayHelper::index($users, 'id');                                   // [id => row]
ArrayHelper::index($users, 'id', 'group');                          // [group => [id => row]]
ArrayHelper::index($users, null, 'group');                          // [group => [row, row]]

// объединение и сортировка
// рекурсивно, с UnsetArrayValue / ReplaceArrayValue
$config = ArrayHelper::merge($base, $override);
ArrayHelper::multisort($data, ['age', 'name'], [SORT_ASC, SORT_DESC]);
ArrayHelper::multisort($data, function ($item) { return $item['a'] . $item['b']; });

// прочее
ArrayHelper::toArray($model, [User::class => ['id', 'email', 'fullName' => function ($u) { return $u->name; }]]);
ArrayHelper::isAssociative($arr); ArrayHelper::isIndexed($arr);
ArrayHelper::isIn('a', $arr); ArrayHelper::isSubset(['a'], $arr);
ArrayHelper::htmlEncode($data); ArrayHelper::htmlDecode($data);     // рекурсивно
ArrayHelper::filter($array, ['A.B', '!A.C']);                       // оставить/исключить ключи
ArrayHelper::isTraversable($x);

merge() — то, что yii\web\Application делает с конфигурациями: new \yii\helpers\UnsetArrayValue() удаляет ключ, ReplaceArrayValue заменяет массив целиком вместо слияния.

Html#

Все методы экранируют данные (кроме уже готового HTML в content):

PHP
use yii\helpers\Html;

Html::encode($text); Html::decode($html);
// null — атрибут не выводится
Html::tag('div', Html::encode($content), ['class' => 'box', 'data' => ['id' => 5], 'id' => null]);
Html::beginTag('div', ['class' => 'x']) … Html::endTag('div');
Html::a('Профиль', ['user/view', 'id' => 42], ['class' => 'btn']);   // маршрут → URL через Url::to()
Html::a('Сайт', 'https://example.com', ['target' => '_blank']);
Html::mailto('Написать', 'admin@example.com');
Html::img('@web/images/logo.png', ['alt' => 'Logo']);                // псевдонимы понимает
Html::img('/img/x.png', ['srcset' => ['100w' => '/x@1.png', '200w' => '/x@2.png']]);
Html::ul($items, ['item' => function ($item, $index) { return Html::tag('li', Html::encode($item)); }]);
Html::ol($items, ['encode' => true]);
Html::style('.x{color:red}'); Html::script('alert(1)'); Html::cssFile('@web/css/a.css'); Html::jsFile('@web/js/a.js');
Html::csrfMetaTags();

CSS-классы и стили#

PHP
$options = ['class' => 'btn'];
Html::addCssClass($options, 'btn-primary');            // 'btn btn-primary'; дубли не добавляются
Html::addCssClass($options, ['btn-lg', 'x']);
Html::removeCssClass($options, 'btn');
Html::addCssStyle($options, 'width: 100px');           // 'style' => 'width: 100px'
Html::addCssStyle($options, ['color' => 'red'], false); // не перезаписывать существующее
Html::removeCssStyle($options, 'width');
Html::cssStyleFromArray(['width' => '100px']); Html::cssStyleToArray('width: 100px');
Html::renderTagAttributes(['class' => 'a', 'data' => ['x' => 1]]);   // ' class="a" data-x="1"'

Формы#

PHP
Html::beginForm(['order/update', 'id' => $id], 'post', ['enctype' => 'multipart/form-data']) … Html::endForm();
Html::input('text', 'username', $value, ['class' => 'form-control']);
Html::textInput('username', $value); Html::passwordInput('pw'); Html::hiddenInput('id', 1);
Html::textarea('text', $value, ['rows' => 5]);
Html::checkbox('agree', $checked, ['label' => 'Согласен', 'uncheck' => 0]);
Html::radio('type', $checked, ['value' => 'a']);
Html::dropDownList('country', $selected, $items, ['prompt' => '—', 'options' => ['RU' => ['disabled' => true]]]);
Html::listBox('tags', $selected, $items, ['multiple' => true]);
Html::checkboxList('tags', $selected, $items, ['separator' => '<br>']);
Html::radioList('type', $selected, $items, ['item' => function ($index, $label, $name, $checked, $value) { … }]);
Html::submitButton('Сохранить', ['class' => 'btn']); Html::resetButton(); Html::button('Кнопка');
Html::fileInput('file');

// active* — по модели и атрибуту: имя LoginForm[username], id loginform-username, значение из модели
Html::activeTextInput($model, 'username', ['class' => 'form-control']);
Html::activeDropDownList($model, 'country', $items);
Html::activeLabel($model, 'username'); Html::activeHint($model, 'username');
Html::error($model, 'username'); Html::errorSummary($model);
Html::activeCheckbox($model, 'agree');
Html::getInputName($model, 'attr'); Html::getInputId($model, 'attr');

ActiveForm и ActiveField — обёртки над этими active* методами.

Json#

PHP
use yii\helpers\Json;
use yii\web\JsExpression;

// JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES по умолчанию (2.0.x), объекты Arrayable → массивы
Json::encode($data);
// без кавычек — для JS-конфигураций виджетов
Json::encode(['fn' => new JsExpression('function () { return 1; }')]);
Json::htmlEncode($data);                   // безопасно для вставки в HTML-атрибуты и <script>
// всегда массив (assoc = true); бросает InvalidArgumentException при ошибке
Json::decode($json);
Json::decode($json, false);                // stdClass
Json::$prettyPrint = true;                 // отладка (статическое свойство)
Json::errorSummary($model);                // ошибки модели как JSON

JsExpression — единственный способ вставить в JSON JS-код (например, колбэк в опции jQuery-плагина).

Url#

PHP
use yii\helpers\Url;

Url::to(['post/view', 'id' => 1]);           // маршрут → /post/1 через urlManager
Url::to(['post/view', 'id' => 1], true);     // абсолютный: https://host/post/1
Url::to(['post/view', 'id' => 1], 'https'); // с заданной схемой
// ведущий / — от корня приложения (не относительно модуля/контроллера)
Url::to(['/site/index']);
Url::to(['index']);                          // относительно текущего контроллера
Url::to(['']);                               // текущий маршрут (с параметрами — нет; см. current)
Url::to('@web/images/logo.png');             // псевдоним
Url::to('/images/x.png'); Url::to('https://…');   // как есть
Url::to();                                   // текущий URL

Url::toRoute('post/index'); Url::toRoute(['post/view', 'id' => 1]);
Url::current();                              // текущий URL со всеми GET-параметрами
Url::current(['page' => 2]);                 // …с добавленным/заменённым параметром
Url::current(['page' => null]);              // …без параметра
Url::home(); Url::base(); Url::base(true);   // домашняя, базовая (с/без хоста)
Url::canonical();                            // для <link rel="canonical"> — маршрут + параметры действия
Url::remember(); Url::previous();            // сохранить/достать URL (в сессии) — для «вернуться назад»
Url::isRelative($url); Url::ensureScheme('//host/path', 'https');

Правило: маршруты в Html::a(), redirect(), Url::to() — всегда массивом ['controller/action', 'param' => …], а не строками, чтобы работали правила urlManager.

Остальные хелперы#

Inflector
pluralize('person') → people, singularize, camelize, camel2id('PostTag') → post-tag, id2camel, humanize, slug('Привет мир') → privet-mir, titleize, classify, tableize, ordinalize(1) → 1st
StringHelper
byteLength, byteSubstr, truncate($s, 20, '…'), truncateWords, startsWith, endsWith, explode($s, ',', true, true) (строка первым аргументом; trim + skip empty), basename, dirname, mb_ucfirst, base64UrlEncode
FileHelper
normalizePath, findFiles($dir, ['only' => ['*.php'], 'except' => ['/tests/']]), createDirectory, removeDirectory, copyDirectory, getMimeType, getExtensionsByMimeType, localize
VarDumper
dump($var, $depth, $highlight), dumpAsString, export — читаемый вывод любых структур
Console
цвета, ansiFormat, prompt, confirm, select, прогресс-бары, wrapText, getScreenSize
Markdown
process($md, 'gfm'), processParagraph
HtmlPurifier
process($html, $config) — очистка HTML
FormatConverter
ICU ↔ PHP форматы дат (convertDateIcuToPhp)
IpHelper
inRange, expandIPv6, getIpVersion, ip2bin
BaseObject-хелперы для подмены: class Html extends \yii\helpers\BaseHtml и Yii::$classMap['yii\helpers\Html'] = '@app/components/Html.php'

Подмена хелпера: наследуйте от BaseHtml, назовите класс Html в своём пространстве имён и импортируйте его вместо yii\helpers\Html; либо через classMap, чтобы подменить глобально (включая код фреймворка).

Проверь себя5

ArrayHelper::getValue($user, 'address.city', $default) — работает и с объектами.

map() строит [ключ => значение поля] (для списков), index() — [ключ => целый элемент].

Обернуть в new JsExpression('function () {…}') — Json::encode() вставит её без кавычек.

Без / маршрут относительно текущего контроллера (post/index), с / — абсолютный маршрут от корня приложения, даже внутри модуля.

Html::addCssClass($options, 'new-class').

📌 Справочник

47Памятка на одну страницу

Самое нужное из всего руководства в одном месте — команды, конфигурация, контроллер, модель, Active Record, формы, URL, доступ, кэш, миграции, консоль, REST. Для быстрого «как это писалось».

Самое сжатое, что есть в курсе: только код и короткие пояснения. Заголовок каждого блока ведёт в подробный раздел. Держите открытой рядом с редактором.

Установка и команды → Установка#

Консоль
# шаблон basic
composer create-project --prefer-dist yiisoft/yii2-app-basic myapp
# шаблон advanced (после установки: cd myapp && php init)
composer create-project --prefer-dist yiisoft/yii2-app-advanced myapp

./yii serve --port=8080          # dev-сервер → http://localhost:8080
./yii help                       # все команды
./yii migrate                    # применить миграции
./yii cache/flush-all            # сбросить кэш
./yii gii/model --tableName=post --modelClass=Post

Конфигурация → Конфигурации, Приложение#

config/web.phpPHP
return [
    'id' => 'app',
    'basePath' => dirname(__DIR__),
    'bootstrap' => ['log'],
    'language' => 'ru-RU',
    'aliases' => ['@uploads' => '@webroot/uploads'],
    'components' => [
        'db' => [
            'class' => 'yii\db\Connection',
            'dsn' => 'mysql:host=localhost;dbname=app',
            'username' => 'root',
            'password' => '',
            'charset' => 'utf8mb4',
            'enableSchemaCache' => !YII_DEBUG,
        ],
        'cache' => ['class' => 'yii\caching\FileCache'],
        'user' => ['identityClass' => 'app\models\User', 'enableAutoLogin' => true],
        'request' => [
            'cookieValidationKey' => 'random-string',
            'parsers' => ['application/json' => 'yii\web\JsonParser'],
        ],
        'urlManager' => [
            'enablePrettyUrl' => true,
            'showScriptName' => false,
            'rules' => ['post/<id:\d+>' => 'post/view'],
        ],
        'errorHandler' => ['errorAction' => 'site/error'],
        'log' => [
            'traceLevel' => YII_DEBUG ? 3 : 0,
            'targets' => [
                ['class' => 'yii\log\FileTarget', 'levels' => ['error', 'warning']],
            ],
        ],
        'mailer' => ['class' => \yii\symfonymailer\Mailer::class, 'useFileTransport' => YII_ENV_DEV],
    ],
    'modules' => ['admin' => ['class' => 'app\modules\admin\Module']],
    'params' => ['adminEmail' => 'admin@example.com'],
];
PHP
// любой объект по конфигурации
$obj = Yii::createObject([
    'class' => Foo::class,
    'prop' => 1,
    'on eventName' => $handler,     // подписка на событие
    'as behaviorName' => [...],     // поведение
]);

// псевдонимы: @app @web @webroot @runtime @vendor @yii
Yii::getAlias('@app/runtime');
Yii::$app->params['adminEmail'];

Контроллер → Контроллеры, Фильтры, Запрос и ответ#

PHP
class PostController extends Controller
{
    public function behaviors()
    {
        return [
            'access' => [
                'class' => AccessControl::class,
                'rules' => [['allow' => true, 'roles' => ['@']]],
            ],
            'verbs' => [
                'class' => VerbFilter::class,
                'actions' => ['delete' => ['POST']],
            ],
        ];
    }

    public function actions()
    {
        return ['error' => 'yii\web\ErrorAction'];
    }

    public function actionView($id)                 // ?id=1 → $id; нет параметра → 400
    {
        $model = Post::findOne($id);
        if ($model === null) {
            throw new NotFoundHttpException();
        }
        return $this->render('view', ['model' => $model]);   // views/post/view.php + layout
    }

    public function actionCreate()
    {
        $model = new Post();
        if ($model->load(Yii::$app->request->post()) && $model->save()) {
            Yii::$app->session->setFlash('success', 'Сохранено');
            return $this->redirect(['view', 'id' => $model->id]);
        }
        return $this->render('create', ['model' => $model]);
    }

    public function actionApi()
    {
        return $this->asJson(['ok' => true]);
    }
}
PHP
// запрос
$r = Yii::$app->request;
$r->get('id');  $r->post();  $r->isPost;  $r->isAjax;
$r->headers->get('X-Token');  $r->userIP;

// ответ
$this->redirect(['site/index']);   $this->goBack();   $this->goHome();   $this->refresh();
Yii::$app->response->sendFile($path);
Yii::$app->response->statusCode = 201;

// хуки действия
public function beforeAction($action) { /* … */ return parent::beforeAction($action); }
public function afterAction($action, $result) { return parent::afterAction($action, $result); }

Модель и валидация → Модели, Валидация, Валидаторы#

PHP
class ContactForm extends Model
{
    public $name;
    public $email;
    public $body;

    public function rules()
    {
        return [
            [['name', 'email', 'body'], 'required'],
            [['name', 'email'], 'trim'],
            ['email', 'email'],
            ['name', 'string', 'max' => 50],
            ['body', 'filter', 'filter' => 'strip_tags'],
            ['age', 'integer', 'min' => 18, 'on' => 'adult'],
            ['url', 'url', 'defaultScheme' => 'https'],
            ['status', 'in', 'range' => [1, 2]],
            ['status', 'default', 'value' => 1],
            ['password_repeat', 'compare', 'compareAttribute' => 'password'],
            ['username', 'unique', 'targetClass' => User::class],
            ['category_id', 'exist', 'targetClass' => Category::class, 'targetAttribute' => 'id'],
            ['file', 'file', 'extensions' => 'png, jpg', 'maxSize' => 1024 * 1024],
            ['tags', 'each', 'rule' => ['string']],
            ['name', 'validateName'],                                   // inline-валидатор
            ['state', 'required', 'when' => fn ($m) => $m->country === 'US'],
        ];
    }

    public function attributeLabels() { return ['email' => 'E-mail']; }

    public function scenarios()
    {
        return ['create' => ['name', 'email', 'body'], 'update' => ['name']];
    }
}
PHP
$model->load(Yii::$app->request->post());   // массовое присваивание безопасных атрибутов
$model->validate();                          // true / false
$model->errors;  $model->getFirstErrors();  $model->addError('name', 'Занято');

DynamicModel::validateData(compact('email'), [['email', 'email']]);   // без класса

Active Record → Active Record, Query Builder, DAO#

PHP
class Post extends ActiveRecord
{
    public static function tableName() { return '{{%post}}'; }

    public function behaviors() { return [TimestampBehavior::class]; }

    public function getAuthor()
    {
        return $this->hasOne(User::class, ['id' => 'author_id']);
    }

    public function getTags()
    {
        return $this->hasMany(Tag::class, ['id' => 'tag_id'])
            ->viaTable('post_tag', ['post_id' => 'id']);
    }
}
PHP
// чтение
Post::findOne(1);
Post::findOne(['slug' => $slug]);
Post::findAll(['status' => 1]);
Post::find()
    ->where(['status' => 1])
    ->andWhere(['like', 'title', $q])
    ->andFilterWhere(['author_id' => $authorId])   // пропускается, если пусто
    ->with('author')                                // отдельный запрос для связи
    ->joinWith('tags')                              // JOIN — можно фильтровать по tags.*
    ->orderBy(['id' => SORT_DESC])
    ->limit(10)->offset(20)
    ->all();
// ->one()  ->count()  ->exists()  ->column()  ->scalar()  ->asArray()
// ->indexBy('id')  ->batch(100)  ->cache(60)

// форматы where
->where('status = 1')
->where(['status' => 1, 'type' => [1, 2], 'deleted_at' => null])   // = , IN, IS NULL
->where(['and', ['>', 'id', 10], ['or', ['a' => 1], ['b' => 2]]])
->where(['between', 'price', 10, 100])
->where(['not in', 'id', $ids])

// запись
$post = new Post();
$post->attributes = $data;
$post->save();              // валидация + INSERT/UPDATE
$post->save(false);         // без валидации
$post->delete();
$post->refresh();
Post::updateAll(['status' => 0], ['<', 'created_at', $ts]);   // без событий
Post::deleteAll(['status' => 0]);
$post->updateCounters(['views' => 1]);

// связи
$post->author;                       // ленивая загрузка
$post->link('tags', $tag);
$post->unlink('tags', $tag, true);

// транзакции и DAO
Yii::$app->db->transaction(function ($db) { /* … */ });
$db->createCommand('SELECT * FROM post WHERE id = :id', [':id' => 1])->queryOne();
$db->createCommand()->insert('post', ['title' => 'x'])->execute();
// batchInsert()  update()  delete()  upsert()

Представления и формы → Представления, Формы, Виджеты данных#

views/post/create.phpPHP
<?php
$this->title = 'Новый пост';
$this->params['breadcrumbs'][] = $this->title;
$this->registerJs("console.log('hi')");
AppAsset::register($this);
?>
<h1><?= Html::encode($this->title) ?></h1>
<?= $this->render('_form', ['model' => $model]) ?>
views/post/_form.phpPHP
<?php $form = ActiveForm::begin([
    'id' => 'post-form',
    'options' => ['enctype' => 'multipart/form-data'],
]) ?>

<?= $form->field($model, 'title')->textInput(['maxlength' => true]) ?>
<?= $form->field($model, 'body')->textarea(['rows' => 6]) ?>
<?= $form->field($model, 'category_id')->dropDownList(
    ArrayHelper::map(Category::find()->all(), 'id', 'name'),
    ['prompt' => '—']
) ?>
<?= $form->field($model, 'agree')->checkbox() ?>
<?= $form->field($model, 'file')->fileInput() ?>

<?= Html::submitButton('Сохранить', ['class' => 'btn btn-primary']) ?>

<?php ActiveForm::end() ?>
PHP
// файл из формы; имя генерируем своё — присланному доверять нельзя
$model->file = UploadedFile::getInstance($model, 'file');
$name = Yii::$app->security->generateRandomString(16);
$model->file->saveAs('@webroot/uploads/' . $name . '.' . $model->file->extension);

// список
$dataProvider = new ActiveDataProvider([
    'query' => Post::find(),
    'pagination' => ['pageSize' => 20],
    'sort' => ['defaultOrder' => ['id' => SORT_DESC]],
]);

echo GridView::widget([
    'dataProvider' => $dataProvider,
    'filterModel' => $searchModel,
    'columns' => [
        'id',
        'title',
        'created_at:datetime',
        ['class' => ActionColumn::class],
    ],
]);

echo DetailView::widget([
    'model' => $model,
    'attributes' => ['title', 'author.name', 'created_at:date'],
]);
// ListView::widget(['dataProvider' => $dp, 'itemView' => '_item'])

URL и маршруты → Маршрутизация#

PHP
'rules' => [
    '' => 'site/index',
    'login' => 'site/login',
    'post/<id:\d+>' => 'post/view',
    'posts/<page:\d+>' => 'post/index',
    '<controller:\w+>/<action:\w+>' => '<controller>/<action>',
    'PUT,PATCH users/<id>' => 'user/update',
    'DELETE users/<id>' => 'user/delete',
    ['class' => 'yii\rest\UrlRule', 'controller' => 'user'],
    'http://<sub:\w+>.example.com/<page>' => 'site/page',
],
PHP
Url::to(['post/view', 'id' => 1]);        // /post/1
Url::to(['/site/index'], true);           // абсолютный, от корня приложения
Url::current(['page' => 2]);              // текущий URL с изменённым параметром
Html::a('Пост', ['post/view', 'id' => 1]);

// маршрут: module/controller/action
// PostTagController → post-tag, actionViewAll → view-all

Пользователи и доступ → Аутентификация, Авторизация, Безопасность#

PHP
// модель User реализует IdentityInterface:
// findIdentity(), findIdentityByAccessToken(), getId(), getAuthKey(), validateAuthKey()

Yii::$app->user->login($user, $rememberMe ? 3600 * 24 * 30 : 0);
Yii::$app->user->logout();
Yii::$app->user->isGuest;   Yii::$app->user->id;   Yii::$app->user->identity;
Yii::$app->user->can('updatePost', ['post' => $post]);

Yii::$app->security->generatePasswordHash($password);
Yii::$app->security->validatePassword($password, $hash);
Yii::$app->security->generateRandomString();
PHP
// ACF
'rules' => [
    ['allow' => true, 'actions' => ['login'], 'roles' => ['?']],   // гости
    ['allow' => true, 'roles' => ['@']],                            // вошедшие
    ['allow' => true, 'matchCallback' => fn () => Yii::$app->user->can('admin')],
    // также: 'ips', 'verbs', 'controllers', 'denyCallback'
],

// RBAC
$auth = Yii::$app->authManager;
$updatePost = $auth->createPermission('updatePost');
$auth->add($updatePost);
$author = $auth->createRole('author');
$auth->add($author);
$auth->addChild($author, $updatePost);
$auth->assign($author, $userId);
// правило: class MyRule extends Rule { public function execute($user, $item, $params) {…} }

Безопасность по умолчанию: CSRF включён, Html::encode() везде, параметры в SQL, YII_DEBUG = false в production, случайный cookieValidationKey.

PHP
$session = Yii::$app->session;
$session->set('key', $value);   $session->get('key', $default);   $session->remove('key');
$session['key'] = $value;       $session->has('key');

$session->setFlash('success', 'Готово');          // показать на следующей странице
$session->getFlash('success');   $session->hasFlash('success');   $session->getAllFlashes();

Yii::$app->response->cookies->add(new Cookie([
    'name' => 'lang',
    'value' => 'ru',
    'expire' => time() + 86400 * 365,
]));
Yii::$app->request->cookies->getValue('lang', 'en');   // cookie подписаны cookieValidationKey

Кэш → Кэширование данных, Кэширование вывода#

PHP
$posts = Yii::$app->cache->getOrSet(['posts', $page], function () {
    return Post::find()->all();
}, 3600, new TagDependency(['tags' => 'posts']));

TagDependency::invalidate(Yii::$app->cache, 'posts');   // сбросить по тегу
$cache->set($key, $value, 60);   $cache->get($key);   $cache->delete($key);   $cache->flush();
Post::find()->cache(60)->all();                          // кэш запроса
PHP
// фрагмент представления
<?php if ($this->beginCache('sidebar', ['duration' => 600, 'variations' => [Yii::$app->language]])) { ?>
    …
<?php $this->endCache(); } ?>

// страница целиком и HTTP-кэш — фильтры контроллера
['class' => PageCache::class, 'only' => ['index'], 'duration' => 60],
['class' => HttpCache::class, 'lastModified' => fn () => $timestamp, 'etagSeed' => fn () => $seed],

Миграции → Миграции#

Консоль
./yii migrate/create create_post_table \
    --fields="title:string:notNull,body:text,author_id:integer:notNull:foreignKey(user)"
./yii migrate/create add_status_column_to_post_table --fields="status:smallInteger:defaultValue(1)"

./yii migrate              # применить
./yii migrate/down         # откатить последнюю
./yii migrate/redo         # откатить и применить снова
./yii migrate/history      # что применено
./yii migrate/new          # что ещё нет
./yii migrate --migrationPath=@yii/rbac/migrations --interactive=0
PHP
public function safeUp()
{
    $this->createTable('{{%post}}', [
        'id' => $this->primaryKey(),
        'title' => $this->string()->notNull(),
        'author_id' => $this->integer()->notNull(),
        'created_at' => $this->integer(),
    ]);
    $this->createIndex('idx-post-title', '{{%post}}', 'title');
    $this->addForeignKey('fk-post-author', '{{%post}}', 'author_id', '{{%user}}', 'id', 'CASCADE');
}

public function safeDown()
{
    $this->dropTable('{{%post}}');
}

События, поведения, DI → События, Поведения, DI#

PHP
// события
$obj->on(Foo::EVENT_X, function ($event) {
    $event->sender;   $event->data;
    $event->handled = true;                        // остановить остальные обработчики
}, $data);
$obj->trigger(Foo::EVENT_X, new Event());
Event::on(ActiveRecord::class, ActiveRecord::EVENT_AFTER_INSERT, $handler);   // на класс

// поведения
public function behaviors()
{
    return [
        TimestampBehavior::class,
        ['class' => SluggableBehavior::class, 'attribute' => 'title'],
        BlameableBehavior::class,
    ];
}

// DI
Yii::$container->set('app\components\PaymentInterface', 'app\components\Stripe');
Yii::$container->setSingleton(Mailer::class, [...]);
$obj = Yii::createObject(Foo::class, [$arg]);

// внедрение в контроллер
public function __construct($id, $module, PaymentInterface $payment, $config = [])
{
    $this->payment = $payment;
    parent::__construct($id, $module, $config);
}

Консоль, REST, i18n, почта → Консоль, REST, i18n, Почта#

PHP
// ./yii import/run data.csv --dry=1
class ImportController extends \yii\console\Controller
{
    public $dry = false;

    public function options($actionID) { return ['dry']; }

    public function actionRun($file, array $ids = [])
    {
        $this->stdout("ok\n", Console::FG_GREEN);
        return ExitCode::OK;
    }
}

// REST: + yii\rest\UrlRule, JsonParser, HttpBearerAuth
class UserController extends \yii\rest\ActiveController
{
    public $modelClass = User::class;
}
public function fields()
{
    $fields = parent::fields();
    unset($fields['password_hash']);
    return $fields;
}
public function extraFields() { return ['profile']; }

// i18n: messages/ru-RU/app.php, извлечение — ./yii message
Yii::t('app', 'Hello, {name}! {n, plural, one{# message} other{# messages}}', [
    'name' => $name,
    'n' => $count,
]);

// почта
Yii::$app->mailer->compose('welcome', ['user' => $user])
    ->setTo($user->email)
    ->setSubject('Добро пожаловать')
    ->send();

// логи, профилирование, исключения
Yii::info('msg', 'category');   Yii::warning();   Yii::error();   Yii::debug();
Yii::beginProfile('block');   Yii::endProfile('block');
throw new NotFoundHttpException();      // 404; также Forbidden (403), BadRequest (400)
throw new UserException('Текст');       // сообщение показывается пользователю

Хелперы → Хелперы, Форматирование#

PHP
ArrayHelper::getValue($arr, 'user.name', $default);
ArrayHelper::map($rows, 'id', 'name');       ArrayHelper::index($rows, 'id');
ArrayHelper::getColumn($rows, 'id');         ArrayHelper::merge($a, $b);

Html::encode($text);                         Html::a($text, ['post/view', 'id' => 1]);
Html::img('@web/img/logo.png');              Html::tag('div', $content, ['class' => 'x']);
Html::addCssClass($options, 'active');

Json::encode($data);   Json::decode($json);   new JsExpression('function () {}');
Url::to([...]);   Url::current();   Url::home();   Url::previous();

Inflector::slug('Привет мир');               // privet-mir
Inflector::pluralize('post');                Inflector::camel2id('PostTag');
StringHelper::truncate($text, 50);           VarDumper::dump($value, 10, true);

$f = Yii::$app->formatter;
$f->asDate($ts, 'php:d.m.Y');   $f->asDatetime($ts);   $f->asRelativeTime($ts);
$f->asCurrency($sum, 'RUB');    $f->asDecimal($n, 2);   $f->asShortSize($bytes);
$f->asBoolean($flag);           $f->asNtext($text);

48Глоссарий

Ключевые термины Yii 2 в алфавитном порядке с короткими определениями и ссылками на разделы, где о них рассказано подробно.

Короткие определения терминов, которые встречаются по всему руководству. Ссылка ведёт в раздел, где термин разобран.

А–Д#

Active Record (AR)
класс, представляющий строку таблицы: атрибуты = столбцы, save()/delete(), связи hasOne/hasMany. → Active Record
ActiveDataProvider
провайдер данных, который берёт страницу моделей из ActiveQuery с пагинацией и сортировкой. → Провайдеры данных
ActiveForm / ActiveField
виджет формы и одно её поле, привязанные к модели: label, input, ошибка, JS-валидация. → Формы
ActiveQuery
объект запроса для AR (наследует Query): where(), with(), joinWith(), all(). → Active Record
Asset Bundle
класс с набором CSS/JS-файлов и зависимостями; регистрируется в представлении и публикуется в web/assets. → Ассеты
ACF (Access Control Filter)
фильтр AccessControl с правилами allow/deny по ролям, IP, методам. → Авторизация
Псевдоним (alias)
имя вида @app, заменяющее путь или URL; Yii::getAlias(). → Конфигурации
Действие (action)
метод контроллера actionXxx() или класс Action; конечная точка маршрута. → Контроллеры
Автозагрузка
поиск файла класса по namespace и псевдониму (app\models\User → @app/models/User.php). → Конфигурации
Аутентификация
установление личности пользователя; компонент user + IdentityInterface. → Аутентификация
Авторизация
проверка прав: ACF или RBAC (can()). → Авторизация
Бутстрап (bootstrap)
компоненты/классы, инициализируемые при старте приложения ('bootstrap' => ['log'], BootstrapInterface). → Жизненный цикл
Behavior (поведение)
объект, добавляющий компоненту методы, свойства и обработчики событий без наследования. → Поведения
Валидатор
класс правила проверки (required, email, свой). → Валидация, Валидаторы
Виджет
переиспользуемый блок представления (Widget::widget() / begin()…end()). → Виджеты
Входной скрипт
web/index.php / yii: подключает автозагрузчик, создаёт приложение, run(). → Приложение
Грязные атрибуты (dirty)
изменённые после загрузки атрибуты AR; только они попадают в UPDATE. → Active Record
DAO
Data Access Objects: Connection/Command — работа с SQL напрямую, с привязкой параметров. → DAO
DI-контейнер
Yii::$container: создаёт объекты, разрешая зависимости по типам конструктора. → DI
DataProvider
источник постраничных данных для GridView/ListView/REST. → Провайдеры данных
Зависимость кэша
условие инвалидации (TagDependency, DbDependency, FileDependency…). → Кэширование данных

Ж–П#

Жадная загрузка (eager)
with('rel'): связанные записи одним запросом для всех моделей; против N+1. → Active Record
Ленивая загрузка (lazy)
$model->rel: запрос при первом обращении. → Active Record
Компонент
yii\base\Component: свойства, события, поведения. Компонент приложения — Yii::$app->id. → Компоненты
Конфигурация
массив ['class' => …, 'prop' => …], описывающий объект; Yii::createObject(). → Конфигурации
Контроллер
класс, группирующий действия; Controller → render(), redirect(). → Контроллеры
Layout (шаблон)
общая обёртка страниц (views/layouts/main.php), получает $content. → Представления
Массовое присваивание
$model->attributes = $data / load(); только безопасные атрибуты. → Модели
Миграция
класс, меняющий схему БД, с up()/down(); команда migrate. → Миграции
Модель
yii\base\Model: атрибуты, правила, сценарии, подписи, ошибки. → Модели
Модуль
мини-приложение с контроллерами, моделями и представлениями; modules в конфигурации. → Модули
Маршрут (route)
строка module/controller/action; urlManager разбирает и строит URL. → Маршрутизация
N+1
1 запрос за списком + по запросу на связь каждой строки; лечится with(). → Active Record
Оптимистичная блокировка
столбец версии; при несовпадении StaleObjectException. → Active Record
Пагинация
Pagination + LinkPager: page, per-page, offset/limit. → Провайдеры данных
Параметры (params)
Yii::$app->params['adminEmail'] — произвольные настройки приложения. → Приложение
Провайдер данных
см. DataProvider.
Представление (view)
PHP-шаблон в views/; $this — yii\web\View. → Представления

Р–Я#

RBAC
управление доступом на основе ролей: разрешения, роли, правила, authManager. → Авторизация
REST
yii\rest\ActiveController + UrlRule: CRUD-API поверх AR. → REST
Расширение
Composer-пакет yii2-extension с bootstrap и псевдонимами. → Расширения
Связь (relation)
hasOne/hasMany (+via/viaTable) между AR-классами. → Active Record
Сервис-локатор
Yii::$app/модуль: хранит компоненты по ID, создаёт лениво. → DI
Сессия
Yii::$app->session: данные между запросами; flash-сообщения. → Сессии и cookie
Событие
on()/trigger(); Event с sender, data, handled, isValid. → События
Сценарий (scenario)
режим модели, определяющий активные правила и безопасные атрибуты. → Модели
Тема (theme)
подмена путей представлений (pathMap) без изменения кода. → Представления
Фильтр
поведение контроллера с beforeAction/afterAction: AccessControl, VerbFilter, PageCache, Cors… → Фильтры
Фикстура
воспроизводимые данные для тестов (ActiveFixture). → Тестирование
Форматтер
Yii::$app->formatter: asDate, asCurrency, asSize… → Форматирование
Фрагментное кэширование
beginCache()/endCache() в представлении. → Кэширование вывода
Хелпер
статический класс-утилита: Html, Url, ArrayHelper, Json. → Хелперы
Шаблон приложения
basic (одно приложение) или advanced (frontend/backend/console/common). → Установка
CSRF
подделка межсайтовых запросов; токен _csrf в формах и заголовке. → Безопасность
Gii
генератор кода: модели, CRUD, контроллеры, модули. → Инструменты
HATEOAS
ссылки _links в ответах API (Linkable). → REST
Identity
класс, реализующий IdentityInterface; Yii::$app->user->identity. → Аутентификация
i18n
интернационализация: Yii::t(), источники переводов, ICU-формат. → i18n
Pjax
виджет, обновляющий часть страницы через AJAX + pushState. → Виджеты
Query Builder
yii\db\Query: объектная сборка SQL под любую СУБД. → Query Builder
Response
Yii::$app->response: статус, заголовки, формат, sendFile(). → Запрос и ответ
Service Locator
см. Сервис-локатор.
UrlManager
компонент разбора и генерации URL по правилам. → Маршрутизация
View (объект)
yii\web\View: render(), registerJs(), title, params, блоки. → Представления

49Переход с Yii 1.1

Что изменилось между Yii 1.1 и 2.0 — PHP 5.4+ и пространства имён, Yii::app() → Yii::$app, BaseObject/Component, события через on(), псевдонимы с @, представления и виджеты, модели и сценарии, контроллеры возвращают результат, Active Record без model(), Composer вместо копирования, совместная работа 1.1 и 2.0.

Yii 2 — не апгрейд, а переписанный фреймворк: код 1.1 нужно переносить, а не мигрировать. Зато идеи те же — компоненты, AR, виджеты — и большинство изменений сводятся к таблице «было → стало». Обе версии могут работать в одном приложении, пока идёт переезд.

Основа#

Yii 1.1 Yii 2
PHP 5.1+, без namespace, классы с префиксом C (CController)PHP 5.4+ (реально 7.x/8.x), namespaces (yii\web\Controller), PSR-4
Yii::app()Yii::$app
Yii::import(), Yii::app()->getComponent()автозагрузчик по namespace; Yii::$app->db
CComponent — всё: свойства, события, поведенияyii\base\BaseObject (свойства) и yii\base\Component (+события, поведения)
Yii::createComponent($config)Yii::createObject($config) (через DI-контейнер)
protected/, index.php в корнеweb/index.php отдельно от кода; app\ namespace
копирование framework/Composer: yiisoft/yii2 + расширения yiisoft/yii2-*
yiic./yii

События, свойства, поведения#

PHP
// Yii 1.1
$component->onClick = $callback;                 // подписка
$component->onClick(new CEvent($this));          // вызов
// Yii 2
$component->on('click', $callback);
$component->trigger('click', new Event(['sender' => $this]));
$component->off('click', $callback);
Event::on(ActiveRecord::class, ActiveRecord::EVENT_AFTER_INSERT, $handler);   // уровень класса — новое
  • Свойства через getX()/setX() работают так же, но объявляются в BaseObject; конструктор принимает $config и вызывает init().
  • Поведения — yii\base\Behavior с методом events(); концепция та же, что у CBehavior, attachBehavior() сохранён.
  • Псевдонимы: application.models.User → @app/models/User; символ @ обязателен; Yii::getAlias().

Представления и виджеты#

Yii 1.1 Yii 2
$this в представлении — контроллер$this — объект yii\web\View; контроллер — $this->context
$this->renderPartial() в представлении$this->render() — файл; renderPartial() тоже есть (без layout) в контроллере
echo $this->render() в действииreturn $this->render() — действие возвращает результат
$this->widget('CMenu', [...])echo Menu::widget([...]); ActiveForm::begin()/end()
$this->beginWidget()/endWidget()Widget::begin() / Widget::end()
CHtml::encode(), CHtml::link()Html::encode(), Html::a()
Yii::app()->clientScript->registerScriptFile()Asset Bundles + $this->registerJsFile() в View
темы через themeManagerview.theme с pathMap

Layout: $content остался; <?php $this->head() ?>, beginBody(), endBody(), beginPage(), endPage() — обязательны для ассетов.

Модели#

Yii 1.1 Yii 2
CModel, CFormModelyii\base\Model — и для форм
$model->attributes = $_POST['Form']$model->load(Yii::$app->request->post())
safeAttributes()/сценарии через rulesscenarios() + rules() с on; безопасные атрибуты — из активных правил
CValidatoryii\validators\Validator; inline-валидаторы с той же сигнатурой
CActiveRecord, User::model()->findByPk(1)yii\db\ActiveRecord, User::findOne(1) — статические методы, model() нет
CDbCriteria, findAll($criteria)User::find()->where([...])->all() — Query Builder
relations() с self::HAS_MANYgetOrders() → $this->hasMany(Order::class, ['customer_id' => 'id'])
with('orders'), together()with('orders') (отдельный запрос), joinWith('orders') (JOIN)
$model->isNewRecord, save(), beforeSave()то же; события EVENT_BEFORE_INSERT и др.
CDbConnection, createCommand()yii\db\Connection — почти тот же API; {{%table}}, [[column]]

Контроллеры и маршрутизация#

  • Действия — actionIndex() как раньше, но возвращают результат (return $this->render(...)), могут вернуть строку, Response, массив (для JSON).
  • filters() + accessRules() → behaviors() с AccessControl, VerbFilter.
  • CUrlManager → yii\web\UrlManager; правила похожи; enablePrettyUrl, showScriptName.
  • Yii::app()->user->checkAccess() → Yii::$app->user->can(); CWebUser → yii\web\User + IdentityInterface (реализуется моделью User, а не UserIdentity).
  • CUserIdentity::authenticate() — нет; проверка пароля в вашей LoginForm, затем Yii::$app->user->login($identity, $duration).
  • CHttpRequest → yii\web\Request (Yii::$app->request->post()), ответ — yii\web\Response (Yii::$app->response).

Прочее#

Yii 1.1 Yii 2
Yii::t('app', ...)то же, но формат ICU ({n, plural, ...}), источники в i18n
CFormatter, Yii::app()->formatYii::$app->formatter
CCacheyii\caching\*; те же зависимости, getOrSet()
CConsoleCommandyii\console\Controller — действия и опции
CAssetManager::publish()Asset Bundles — классы с sourcePath, css, js, depends
Gii, yii-debug-toolbaryii2-gii, yii2-debug
CJavaScript::encodeJson::encode + JsExpression
CDbMigrationyii\db\Migration — safeUp(), migrate/create с шаблонами
yiilite.phpнет — используйте opcache
CJSON, CVarDumperJson, VarDumper

Yii 1.1 и 2.0 в одном приложении#

Переезжать можно постепенно: старое приложение остаётся на 1.1, новые части пишутся на 2.0. Оба фреймворка используют класс Yii, поэтому нужен один общий, обслуживающий обе версии:

components/Yii.phpPHP
$yii2path = '/path/to/yii2';
require $yii2path . '/BaseYii.php';     // Yii 2.x

$yii1path = '/path/to/yii1';
require $yii1path . '/YiiBase.php';     // Yii 1.x

class Yii extends \yii\BaseYii
{
    // скопировать сюда код YiiBase из Yii 1.x
}

Yii::$classMap = include $yii2path . '/classes.php';
Yii::registerAutoloader(['Yii', 'autoload']);   // автозагрузчик Yii 2 через Yii 1
Yii::$container = new yii\di\Container;
index.phpPHP
require __DIR__ . '/../components/Yii.php';

$yii2Config = require __DIR__ . '/../config/yii2/web.php';
new yii\web\Application($yii2Config);          // создать, но НЕ вызывать run()

$yii1Config = require __DIR__ . '/../config/yii1/main.php';
Yii::createWebApplication($yii1Config)->run();  // запускается приложение 1.1

После этого Yii::app() — приложение 1.1 (CWebApplication), а Yii::$app — приложение 2.0, и из старого кода можно вызывать модели, компоненты и хелперы 2.0. Подробности — в разделе расширения и интеграция.

Проверь себя5

Yii::$app вместо Yii::app().

Статический User::findOne($id); сложные запросы — User::find()->where(...)->all().

Они возвращают результат (return $this->render(...)) вместо echo; результат становится ответом.

Геттерами: getOrders() возвращает $this->hasMany(Order::class, ['customer_id' => 'id']).

Его заменил IdentityInterface, который реализует модель пользователя; проверка пароля — в форме входа.