Всё одной страницей
49 разделов подряд — удобно для поиска по Ctrl+F и для печати в PDF.
🚀 Старт
Что такое Yii 2
Зачем нужен Yii, чем он отличается от других PHP-фреймворков, что нужно знать до старта и как устроен этот курс.
Yii (читается «йи», расшифровывается как Yes It Is) — высокопроизводительный компонентный PHP-фреймворк для быстрой разработки веб-приложений. Ветка 2.0 — полностью переписанный фреймворк на Composer, PSR, пространствах имён и трейтах. Именно о ней весь курс.
Yii в пяти пунктах#
код организован по паттерну «модель–представление–контроллер», а поверх него — приложение, компоненты, модули, фильтры и виджеты.
из коробки есть 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 в общих чертах. Каждый узел — раздел этого курса.
входной скрипт, приложение и его компоненты, контроллеры, модели, представления, модули, фильтры, виджеты, ресурсы, расширения.
жизненный цикл, маршрутизация и ЧПУ, запрос и ответ, сессии и куки, ошибки и логи.
компоненты и свойства, события, поведения, конфигурации и псевдонимы, Service Locator и DI-контейнер.
формы, валидация и все валидаторы; форматирование, провайдеры данных, GridView и другие виджеты.
Экосистема#
- Официальные расширения
yiisoft/yii2-*: debug-панель, Gii, Bootstrap-виджеты, jQuery UI, Redis, MongoDB, Elasticsearch, Sphinx, Swiftmailer, Twig, Smarty, authclient (OAuth), httpclient, imagine, faker, apidoc. Подробнее — в разделе Расширения. - Документация: полное руководство (первоисточник этого курса), справочник классов API, вики-статьи сообщества с готовыми рецептами.
- Сообщество: форум, GitHub yiisoft/yii2, каталог расширений на тысячи пакетов.
Как пользоваться курсом#
- Идите по порядку. Разделы выстроены как курс: сначала установка и первое приложение, затем архитектура, потом БД, формы, безопасность, кеш, REST и инструменты. В конце каждой страницы — ссылка на следующую.
- Ищите по коду. Поиск (Ctrl+K или /) ищет не только по заголовкам, но и по именам классов и методов в примерах:
findOne,AccessControl,Url::to. - Проверяйте себя. Внизу разделов — карточки «вопрос → ответ». Попробуйте ответить, прежде чем раскрывать.
- Отмечайте прогресс. Галочка «Изучено» сохраняется в браузере; на главной видно, что осталось.
- Сверяйтесь с первоисточником. Под каждым разделом перечислены главы руководства, из которых он сжат.
«Йи» ([ji:]). В китайском — «простой и эволюционирующий»; также акроним Yes It Is.
2.0 переписана с нуля: Composer, стандарты PSR, пространства имён, трейты. 1.1 — предыдущее поколение в режиме поддержки.
ООП в PHP (включая пространства имён и трейты), Composer и работу в командной строке.
Крупные: порталы, форумы, CMS, магазины, RESTful-приложения — благодаря компонентной структуре и кешированию.
Установка и запуск
Ставим 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, но на боевых серверах так делать не стоит.
- Скачайте архив с yiiframework.com/download.
- Распакуйте в папку, доступную веб-серверу.
- В
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.
- одно приложение, один
web/; - удобен для небольших проектов и обучения;
- всё в пространстве имён
app\.
- три приложения:
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 прячут правилами перезаписи (см. ЧПУ).
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 не поймёт, что соединение защищённое.
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 задаёт две константы до загрузки фреймворка:
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) и нет доступа к конфигу сервера:
- Переименуйте
web/в имя webroot хостинга (например,public_html) ещё локально, а остальные папки загрузите на уровень выше. - Для Apache положите
.htaccessс правилами перезаписи рядом сindex.php(см. выше). Для Nginx ничего не нужно. - Проверьте требования, скопировав
requirements.phpв webroot, и удалите его после проверки. - Для 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 перечисляют папки, которым после установки нужны права на запись.
Снаружи должен быть доступен только входной скрипт и статика. Конфиги, код, runtime/ и vendor/ остаются недосягаемыми по HTTP.
Страницы ошибок будут показывать стек вызовов и фрагменты исходного кода всем посетителям — утечка информации о внутренностях приложения.
Три отдельных приложения (frontend, backend, console) с общим common, механизм окружений php init, готовые регистрация и восстановление пароля.
Чтобы Yii::$app->request->isSecureConnection возвращал true и генерировались правильные абсолютные URL при HTTPS.
php requirements.php в корне проекта (или открыть /requirements.php в браузере).
Первое приложение
Четыре шага из руководства «Первое знакомство» в одном месте: страница «Привет», форма с валидацией, список из базы данных и генерация CRUD в Gii.
Здесь весь путь новичка: действие и представление → модель и форма → Active Record с пагинацией → CRUD за минуту в Gii. Каждый шаг показывает одну часть MVC и знакомит с идеями, которые дальше разбираются подробно.
Шаг 1. Страница «Привет»: действие + представление#
Действие — публичный метод контроллера с префиксом action. Параметры действия берутся из $_GET по имени.
namespace app\controllers;
use yii\web\Controller;
class SiteController extends Controller
{
public function actionSay($message = 'Привет')
{
return $this->render('say', ['message' => $message]);
}
}<?php use yii\helpers\Html; ?>
<?= Html::encode($message) ?>Открываем index.php?r=site/say&message=Привет+мир. Параметр r — это маршрут вида контроллер/действие; site/say → SiteController::actionSay().
Правила именования, которые нужно запомнить:
| Идентификатор | Класс / метод |
|---|---|
контроллер site | app\controllers\SiteController |
контроллер post-comment | app\controllers\PostCommentController |
действие say | actionSay() |
действие create-comment | actionCreateComment() |
представление say контроллера site | views/site/say.php |
render() автоматически оборачивает результат в шаблон views/layouts/main.php — поэтому у новой страницы сразу есть шапка и подвал сайта.
Шаг 2. Форма: модель + валидация#
Модель формы наследует yii\base\Model: публичные свойства — это атрибуты, метод rules() — правила проверки.
namespace app\models;
use yii\base\Model;
class EntryForm extends Model
{
public $name;
public $email;
public function rules()
{
return [
[['name', 'email'], 'required'],
['email', 'email'],
];
}
}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]); // первый показ или ошибки
}<?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:
return [
'class' => 'yii\db\Connection',
'dsn' => 'mysql:host=localhost;dbname=yii2basic',
'username' => 'root',
'password' => '',
'charset' => 'utf8',
];Класс Active Record без единой строки кода уже умеет всё — имя таблицы выводится из имени класса (Country → country):
namespace app\models;
use yii\db\ActiveRecord;
class Country extends ActiveRecord
{
}$countries = Country::find()->orderBy('name')->all(); // все строки как объекты
$country = Country::findOne('US'); // по первичному ключу
$country->name = 'U.S.A.';
$country->save(); // UPDATEКонтроллер со списком и постраничной разбивкой:
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,
]);
}
}<?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:
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 и:
- Model Generator: таблица
country→ классCountry. Кнопка Preview покажет файл, diff — отличия от существующего, флажок overwrite перезапишет. - CRUD Generator:
app\models\Country, поисковая модельapp\models\CountrySearch, контроллерapp\controllers\CountryController. - Получаем
controllers/CountryController.php,models/CountrySearch.phpиviews/country/*.php— таблицу с сортировкой и фильтрами, просмотр, создание, редактирование, удаление.
app\controllers\PostCommentController::actionCreateReply() — дефисы убираются, слова капитализируются, добавляются суффикс Controller и префикс action.
Копирует $_POST['EntryForm'] в безопасные атрибуты модели и возвращает true; если данных нет — false, и действие просто показывает форму.
И на клиенте (JavaScript, сгенерированный из rules()), и на сервере при вызове validate(). Доверять можно только серверной.
Только настроенное подключение db и таблица country в базе: имя таблицы, атрибуты и типы столбцов читаются из схемы автоматически.
Из соображений безопасности: генератор пишет файлы на диск. Разрешённые адреса задаются в allowedIPs.
🏗 Структура приложения
Приложение и его компоненты
Из чего состоит приложение Yii, что делает входной скрипт, какие свойства приложения стоит настроить и как работают компоненты через Yii::$app.
Приложение Yii — это объект Yii::$app, созданный входным скриптом по массиву-конфигурации. Он держит компоненты (БД, запрос, ответ, пользователь…), знает, где лежат контроллеры и представления, и проводит каждый запрос через свой жизненный цикл.
Что есть в приложении кроме MVC#
единственный PHP-файл, доступный снаружи (web/index.php или консольный yii). Создаёт приложение и запускает обработку запроса.
глобальный объект, координирующий всё остальное; доступен как Yii::$app.
именованные сервисы (db, request, user, cache…), зарегистрированные в конфигурации и создаваемые лениво.
самодостаточные мини-приложения со своими контроллерами, моделями и представлениями. См. Модули.
код до и после действия контроллера: доступ, кеш, CORS. См. Фильтры.
переиспользуемые блоки интерфейса внутри представлений. См. Виджеты.
Входной скрипт#
Он делает пять вещей: объявляет константы, подключает автозагрузчик Composer и класс Yii, читает конфигурацию, создаёт приложение и вызывает run().
<?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();#!/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_DEBUGfalseYII_ENVprod (по умолчанию), dev, test; порождает YII_ENV_PROD, YII_ENV_DEV, YII_ENV_TESTYII_ENABLE_ERROR_HANDLERtruedefined('X') or define('X', v) — идиома «определить, если ещё не определено»: так константу можно переопределить снаружи, например в тестовом входном скрипте.
Объект приложения#
Два вида: yii\web\Application для HTTP и yii\console\Application для команд. Оба наследуют yii\base\Application, которая в свою очередь — модуль и Service Locator.
Обязательные свойства#
return [
'id' => 'basic', // уникальный идентификатор приложения
'basePath' => dirname(__DIR__), // корень с кодом; задаёт псевдоним @app
// ...
];Свойства, которые настраивают чаще всего#
aliases['@uploads' => '@webroot/uploads']. Удобнее, чем Yii::setAlias() в кодеbootstrapBootstrapInterface получит вызов bootstrap($app). Держите список коротким — он выполняется всегдаcomponentscontrollerMapcontrollerNamespaceapp\controllers; admin/post ищется в app\controllers\admin\PostControllermodules'forum' => ['class' => 'app\modules\forum\Module']paramsYii::$app->params['adminEmail']. Обычно подключают config/params.phplanguage / sourceLanguagetimeZonedate_default_timezone_set()catchAll['site/offline', 'param' => 'value']name, version, charsetUTF-8)Свойства с разумными умолчаниями#
defaultRoutesite для web (→ site/index), help для консолиlayout, layoutPath, viewPathmain в @app/views/layouts, представления в @app/views. layout => false отключает шаблонruntimePath@app/runtime, псевдоним @runtime. Должна быть доступна на запись и закрыта от вебаvendorPath@app/vendor, псевдоним @vendorextensions@vendor/yiisoft/extensions.phpenableCoreCommandsmigrate, cache, asset…). По умолчанию trueСобытия приложения#
Обработчики вешают прямо в конфигурации через синтаксис 'on имяСобытия' или кодом после создания приложения:
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 | после действия; в обратном порядке: контроллер → модуль → приложение | обёртка результата |
Жизненный цикл приложения#
- Входной скрипт читает конфигурацию — обычный PHP-массив из
config/web.php. - Создаётся объект приложения:
preInit()применяет критичные свойства (basePath), регистрируется обработчик ошибок, применяется остальная конфигурация,init()вызываетbootstrap()— подключаются расширения и компоненты изbootstrap. run()обрабатывает запрос: событиеbeforeRequest→ разбор маршрута компонентомrequest→ создание модуля/контроллера/действия → выполнение → событиеafterRequest→ отправка ответа.- Скрипт получает код выхода и завершает работу.
Подробнее о каждом шаге — в разделе Жизненный цикл запроса.
Компоненты приложения#
Приложение — это Service Locator: набор именованных объектов, к которым обращаются как Yii::$app->id. Компонент создаётся при первом обращении и дальше переиспользуется в рамках запроса.
'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:
'bootstrap' => ['log'],Встроенные компоненты#
У них фиксированные ID и классы по умолчанию — достаточно указать только нужные свойства.
| ID | Класс | За что отвечает |
|---|---|---|
request | yii\web\Request | параметры запроса, заголовки, куки, разбор маршрута |
response | yii\web\Response | код состояния, заголовки, тело, форматирование ответа |
urlManager | yii\web\UrlManager | разбор и генерация URL, правила ЧПУ |
user | yii\web\User | текущий аутентифицированный пользователь (только web) |
session | yii\web\Session | сессия и flash-сообщения (только web) |
db | yii\db\Connection | соединение с БД; класс и dsn нужно указать явно |
errorHandler | yii\web\ErrorHandler | перехват ошибок и исключений |
log | yii\log\Dispatcher | маршрутизация сообщений лога по целям |
view | yii\web\View | рендеринг представлений, регистрация скриптов |
assetManager | yii\web\AssetManager | публикация и подключение JS/CSS |
formatter | yii\i18n\Formatter | форматирование дат, чисел, размеров |
i18n | yii\i18n\I18N | переводы сообщений |
mailer | yii\symfonymailer\Mailer / yii\swiftmailer\Mailer | отправка писем |
cache | по соглашению yii\caching\Cache | кеш данных; не встроен, но ожидается многими компонентами |
id и basePath. Второе определяет псевдоним @app, от которого считаются runtimePath, viewPath и другие пути.
Лениво — при первом обращении Yii::$app->id. Чтобы создать его при старте каждого запроса, ID добавляют в bootstrap.
beforeAction: приложение → модуль → контроллер. afterAction: контроллер → модуль → приложение.
'catchAll' => ['site/offline'] — все запросы пойдут в это действие.
Чтобы константу можно было определить раньше (например, в тестовом входном скрипте или на сервере) без правки кода — повторный define вызвал бы ошибку.
Контроллеры и действия
Как маршрут превращается в вызов метода, чем встроенные действия отличаются от отдельных, откуда берутся параметры и что может вернуть действие.
Контроллер принимает запрос, дёргает модели и отдаёт результат в представление. Он состоит из действий — публичных методов actionXxx() или отдельных классов. Маршрут post/view — это PostController::actionView().
Минимальный контроллер#
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 контроллера | Класс |
|---|---|
article | app\controllers\ArticleController |
post-comment | app\controllers\PostCommentController |
admin/post-comment | app\controllers\admin\PostCommentController (подпапка без модуля) |
Пространство имён задаёт controllerNamespace приложения (по умолчанию app\controllers). Если соглашение не подходит — например, контроллер из чужой библиотеки — используйте карту:
'controllerMap' => [
'account' => 'app\controllers\UserController',
'article' => [
'class' => 'app\controllers\PostController',
'enableCsrfValidation' => false,
],
],Маршрут без действия → defaultAction контроллера (index); запрос без маршрута → defaultRoute приложения (site). Оба можно переопределить:
class SiteController extends Controller
{
public $defaultAction = 'home';
}Действия#
Встроенные (inline)#
Публичный метод с префиксом action. Имя метода — из ID действия: hello-world → actionHelloWorld.
Отдельные (standalone)#
Класс, унаследованный от yii\base\Action, с методом run(). Регистрируется в карте действий контроллера. Так удобно переиспользовать действия между контроллерами и в расширениях:
public function actions()
{
return [
'error' => 'yii\web\ErrorAction', // страница ошибки
'captcha' => ['class' => 'yii\captcha\CaptchaAction'],
'page' => ['class' => 'yii\web\ViewAction', 'viewPrefix' => 'pages'], // статические страницы
];
}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…) |
| число в консоли | код выхода команды |
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 по имени (в консоли — из аргументов командной строки):
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) — тогда скаляр автоматически обернётся в массив.
Жизненный цикл контроллера#
init()— контроллер создан и сконфигурирован.- Создание действия по ID: сначала карта
actions(), затем методactionXxx(), иначеInvalidRouteException. beforeAction()последовательно у приложения, модуля и контроллера. Вернулfalse— остальные пропускаются, действие не выполняется. Здесь работают фильтры.- Выполнение действия с параметрами из запроса.
afterAction()в обратном порядке: контроллер → модуль → приложение.- Результат уходит в компонент
response.
Что ещё умеет контроллер#
render($view, $params)renderPartial($view, $params)renderAjax($view, $params)renderContent($html)redirect($url, $statusCode = 302)$url может быть маршрутом-массивомgoHome(), goBack(), refresh()Url::remember()), перезагрузка текущей страницыasJson($data), asXml($data)response->format и возвращают объект ответа: его нужно вернуть из действияbehaviors()$layoutfalse — без шаблона$enableCsrfValidationТипичный CRUD#
Так выглядит контроллер, который генерирует Gii, — хороший образец «тонкого» контроллера:
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('Запись не найдена.');
}
}app\controllers\admin\PostCommentController::actionCreateReply() — префикс до слеша трактуется как подпапка пространства имён.
Это самостоятельный класс: его можно переиспользовать в разных контроллерах, настраивать через конфигурацию и распространять в расширениях (ErrorAction, CaptchaAction, ViewAction).
Будет выброшено BadRequestHttpException (HTTP 400) ещё до выполнения действия.
Установить Yii::$app->response->format = Response::FORMAT_JSON и вернуть массив, либо вызвать return $this->asJson($data).
Приложение → модуль → контроллер; любой false останавливает цепочку.
Модели
Атрибуты и метки, сценарии, правила валидации, безопасное массовое присваивание и экспорт модели в массив.
Модель — это данные + правила их проверки + бизнес-логика. Базовый класс yii\base\Model даёт атрибуты, метки, сценарии, валидацию, массовое присваивание и toArray(). Active Record наследует всё это и добавляет работу с таблицей.
Атрибуты#
Атрибуты — это публичные нестатические свойства класса. К ним можно обращаться и как к свойствам, и как к элементам массива:
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():
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):
$model = new User(['scenario' => User::SCENARIO_LOGIN]);
// или
$model->scenario = User::SCENARIO_LOGIN;Сценарий определяет активные атрибуты — те, что проходят валидацию и могут быть присвоены массово. По умолчанию сценарии выводятся из правил rules() (ключи on/except), но их можно объявить явно:
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'], // во всех сценариях
];
}
}Правила валидации#
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']:
$model->attributes = Yii::$app->request->post('ContactForm'); // напрямую
$model->load(Yii::$app->request->post()); // то же, но сам возьмёт $_POST[formName()]load() возвращает true, если данные для модели вообще были в массиве — это удобно для «показать форму или обработать».
Безопасные атрибуты#
Массово присваиваются только безопасные атрибуты — активные в текущем сценарии. Остальные молча игнорируются. Так пользователь не сможет через форму выставить себе role = admin.
public function rules()
{
return [
[['title', 'description'], 'safe'], // безопасны без проверки
];
}
public function scenarios()
{
return [
// ! — проверять, но не присваивать массово
self::SCENARIO_LOGIN => ['username', 'password', '!secret'],
];
}
$model->secret = $value; // только явноЭкспорт в массив#
$array = $model->attributes; // все атрибуты
$array = $model->toArray(); // поля из fields()
$array = $model->toArray([], ['prettyName', 'fullAddress']); // + поля из extraFields()fields() определяет, что попадёт в массив по умолчанию, extraFields() — что можно запросить дополнительно (так работает ?expand= в REST):
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— со своей спецификой.
load() сам находит данные по имени формы ($data['ContactForm']) и возвращает false, если их нет; присваивание attributes работает с уже «распакованным» массивом. Оба присваивают только безопасные атрибуты.
Он не является безопасным: безопасные атрибуты — это активные атрибуты текущего сценария, а по умолчанию сценарии строятся из правил. Добавьте правило safe.
Атрибут валидируется, но не присваивается массово — только явно через $model->secret = ....
Переопределить fields() и убрать поле из массива родительской реализации.
В rules() через 'on' => 'login' / 'on' => 'register' или явно перечислив активные атрибуты в scenarios().
Представления и шаблоны
PHP-шаблоны, правила поиска файлов, передача данных, layouts и блоки, компонент view, темизация и подключение Twig/Smarty.
Представление — обычный PHP-файл с HTML, в котором $this — это компонент yii\web\View. Контроллер вызывает render(), результат оборачивается в шаблон (layout) и уходит в ответ. Главное правило: всё пользовательское — через Html::encode().
Как выглядит представление#
<?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.
Безопасность вывода#
<?= 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)$this->renderAjax('form', $params)$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 можно не писать.
Данные в представлении#
// явно — через второй параметр: массив превращается в переменные через 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.
<?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>© <?= date('Y') ?></footer>
<?php $this->endBody() ?>
</body>
</html>
<?php $this->endPage() ?>Пять служебных вызовов обязательны: beginPage()/endPage() оборачивают страницу, head() — место для мета-тегов и CSS, beginBody()/endBody() — для скриптов в начале и конце <body>. Без них зарегистрированные ресурсы не появятся.
Какой шаблон применится#
layoutконтроллера, если неnull; иначе — первый не-nulllayoutпо цепочке модулей вверх до приложения.- Значение трактуется как: псевдоним (
@app/views/layouts/main), абсолютный путь от папки шаблонов приложения (/main), относительный — от папки шаблонов модуля-контекста (main), либоfalse— без шаблона.
class PostController extends Controller
{
public $layout = 'post'; // @app/views/layouts/post.php только для этого контроллера
}Вложенные шаблоны и блоки#
<?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(); ?><?php $this->beginBlock('sidebar'); ?>
<h3>Похожие статьи</h3>
<?php $this->endBlock(); ?>Блок «записывается» в представлении и выводится в шаблоне — так страница управляет частями общей разметки.
Компонент view#
$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:
Yii::$app->view->on(View::EVENT_END_BODY, function () {
echo '<!-- rendered ' . date('c') . ' -->';
});Статические страницы#
Вместо десятка одинаковых actionAbout() — одно отдельное действие:
public function actions()
{
// ?r=site/page&view=about → views/site/pages/about.php
return ['page' => ['class' => 'yii\web\ViewAction']];
}Темизация#
Тема подменяет файлы представлений без правки кода. Настраивается у компонента view:
'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 дают наследование тем — берётся первый существующий файл:
'@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 через рендереры по расширению файла:
'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) и виджеты; форматирование выносите в хелперы.
Оба не применяют шаблон, но 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, общего для представления и шаблона.
Модули
Как упаковать часть функциональности в самостоятельный модуль: структура, класс Module, маршруты, доступ к модулю, вложенность и URL-правила.
Модуль — это «приложение в приложении»: свои контроллеры, модели, представления, компоненты и параметры. Модули не запускаются отдельно, но их удобно переиспользовать между проектами: форум, блог, админка, версия API.
Структура#
modules/forum/
Module.php класс модуля (обязателен, в корне модуля)
controllers/
DefaultController.php контроллер по умолчанию
PostController.php
models/
views/
layouts/ шаблоны модуля
default/index.php представления контроллера default
post/
migrations/
config.php необязательно: конфигурация модуляКласс модуля#
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.
Контроллеры и представления модуля#
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 модуля.
Подключение и маршруты#
'modules' => [
'forum' => [
'class' => 'app\modules\forum\Module',
'params' => ['postsPerPage' => 20],
],
],Маршрут внутри модуля начинается с его ID: forum/post/index → PostController::actionIndex() модуля forum. Маршрут forum целиком → defaultRoute модуля (default) → DefaultController::actionIndex().
Доступ к модулю из кода#
$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:
'bootstrap' => ['forum'],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);
}
}Вложенные модули#
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), модуль не нужен.
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.
Фильтры
Код до и после действия: как подключать фильтры, в каком порядке они срабатывают, как написать свой и что умеют встроенные — AccessControl, VerbFilter, HttpCache, PageCache, Cors и другие.
Фильтр — это поведение, которое вклинивается в beforeAction() и afterAction() контроллера. Пре-фильтр может остановить выполнение (доступ запрещён, метод не тот), пост-фильтр — обработать результат (кеш, сжатие).
Подключение#
Фильтры объявляют в behaviors() контроллера, модуля или приложения:
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');
},
],
];
}Порядок выполнения#
- Пре-фильтры приложения в порядке объявления в
behaviors(). - Пре-фильтры модуля, затем контроллера — тоже по порядку. Любой вернул
false— стоп, остальные фильтры и действие пропускаются. - Действие.
- Пост-фильтры в обратном порядке: контроллер → модуль → приложение.
Свой фильтр#
Наследуем yii\base\ActionFilter, переопределяем beforeAction() и/или afterAction():
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 — кто может#
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-методами#
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():
'authenticator' => ['class' => \yii\filters\auth\HttpBearerAuth::class],ContentNegotiator — формат ответа и язык#
Смотрит на Accept, GET-параметры и выставляет Response::format и Yii::$app->language. Можно повесить и как компонент bootstrap, чтобы формат определился в самом начале запроса:
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 — кеширование#
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-запроса:
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#
Уровень приложения, если нельзя настроить веб-сервер:
'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-контроллеры |
ContentNegotiator | JSON/XML и язык по Accept | REST, bootstrap |
HttpCache | кеш в браузере | публичные страницы |
PageCache | кеш страницы на сервере | тяжёлые страницы |
RateLimiter | лимит запросов | REST |
Cors | кросс-доменные запросы | API для SPA |
HostControl | белый список хостов | конфигурация приложения |
Остальные пре-фильтры и само действие не выполнятся; пост-фильтры тоже пропускаются.
Браузер сначала шлёт preflight-запрос OPTIONS без токена; если аутентификация сработает первой, она вернёт 401 и CORS-заголовки не уйдут.
В обратном относительно пре-фильтров: сначала контроллер, потом модуль, потом приложение.
VerbFilter с 'delete' => ['POST', 'DELETE'] — на GET вернётся 405.
HttpCache управляет кешем на стороне клиента заголовками Last-Modified/ETag; PageCache хранит готовый HTML на сервере в компоненте кеша.
Виджеты
Переиспользуемые блоки интерфейса: как использовать widget() и begin()/end(), как написать свой виджет с представлением и ресурсами, как задать умолчания через DI.
Виджет — это класс, который рисует кусок интерфейса: форму, меню, таблицу, пагинатор, date picker. Он инкапсулирует разметку, логику и нужные JS/CSS, поэтому в представлении достаточно одной строки.
Использование#
Два способа вызова:
<?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 кнопок:
\Yii::$container->set('yii\widgets\LinkPager', ['maxButtonCount' => 5]);
// или в конфигурации приложения (с 2.0.11)
'container' => [
'definitions' => [
'yii\widgets\LinkPager' => ['maxButtonCount' => 5],
],
],Свой виджет#
Наследуем yii\base\Widget: в init() нормализуем свойства, в run() возвращаем HTML.
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);
}
}<?= HelloWidget::widget(['message' => 'Доброе утро']) ?>Виджет с содержимым#
Чтобы принять то, что написано между begin() и end(), включаем буферизацию вывода в init() и забираем её в run():
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 HelloWidget::begin(); ?>
содержимое, которое может содержать <tag>'и
<?php HelloWidget::end(); ?>Виджет с представлением#
Большую разметку удобнее держать в файле. Представления виджета лежат в views/ рядом с классом (@app/components/views/hello.php); папку меняет getViewPath().
public function run()
{
return $this->render('hello', ['message' => $this->message]);
}Виджет с ресурсами#
Если виджету нужны CSS/JS, регистрируйте пакет ресурсов в $this->view — тогда виджет остаётся самодостаточным:
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). Виджет выигрывает, когда его нужно распространять как класс: между модулями, проектами, в расширении.
widget() сразу возвращает готовый HTML; begin() возвращает объект виджета и ждёт end(), между ними можно выводить содержимое (как в ActiveForm).
В папке views/ рядом с файлом класса виджета; изменить можно переопределив getViewPath().
Зарегистрировать конфигурацию в DI-контейнере: Yii::$container->set(LinkPager::class, ['maxButtonCount' => 5]).
В $this->view — объект представления, в котором он рендерится; лучше всего через пакет ресурсов SomeAsset::register($this->view).
Ресурсы и клиентские скрипты
Asset bundles — единственно правильный способ подключать JS и CSS: зависимости, публикация, Bower/NPM через Composer, объединение и сжатие, а также registerJs/registerCss и yii.js.
Ресурсы (assets) — JS, CSS, картинки, шрифты. Yii управляет ими пакетами (AssetBundle): пакет знает, где лежат файлы, от каких других пакетов зависит, и сам публикует их в веб-доступную папку. Зарегистрировал пакет в представлении — теги <link> и <script> появились в нужном порядке.
Пакет ресурсов#
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',
];
}<?php
use app\assets\AppAsset;
AppAsset::register($this); // $this — объект View; в виджете: $this->view
?>sourcePath@bower/...). Файлы будут опубликованы в @webroot/assetsbasePath + baseUrljs, cssdependsjsOptions, cssOptionsregisterJsFile()/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 уже настроен):
"repositories": [
{"type": "composer", "url": "https://asset-packagist.org"}
],
"require": {
"bower-asset/jquery": "^3.6",
"npm-asset/chart.js": "^4.0"
}'aliases' => [
'@bower' => '@vendor/bower-asset',
'@npm' => '@vendor/npm-asset',
],Дальше пакет ссылается на них через sourcePath = '@npm/chart.js/dist'. Альтернатива — глобальный плагин fxp/composer-asset-plugin, но он заметно медленнее.
Настройка через assetManager#
Все пакеты можно перенастроить централизованно, не трогая их классы:
'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 # объединить, сжать, сгенерировать конфиг пакетов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 собирает всё оставшееся. Результат подключают по окружению:
'assetManager' => [
'bundles' => require __DIR__ . '/' . (YII_ENV_PROD ? 'assets-prod.php' : 'assets-dev.php'),
],Регистрация скриптов и стилей напрямую#
Для мелких динамических кусков — методы View:
$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> в данных сломают страницу:
$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).
sourcePath — если файлы лежат вне веб-корня и их надо опубликовать (расширения, npm-пакеты). basePath/baseUrl — если файлы уже в web/ и публикация не нужна.
Чтобы гарантировать порядок подключения: файлы пакета выводятся после файлов всех его зависимостей (например, плагин после jQuery).
'appendTimestamp' => true у assetManager — к URL добавится ?v=<время изменения файла>.
Ключ блока: повторная регистрация с тем же ключом заменяет предыдущую, а не добавляет дубликат. Без ключа ключом служит сам код.
Такие изменения имеют приоритет над assetManager->bundles и ломают централизованную перенастройку и объединение ресурсов командой yii asset.
Расширения и сторонний код
Как ставить расширения через Composer, как оформить своё (composer.json, bootstrap-класс, ресурсы, миграции), список официальных расширений и как подружить Yii с чужими библиотеками и системами.
Расширение — Composer-пакет типа yii2-extension: виджеты, модули, хелперы, компоненты. Устанавливается одной командой, автозагружается сам, при необходимости регистрирует свой bootstrap-код. Обычные библиотеки (не для Yii) подключаются так же через Composer.
Установка#
composer require yiisoft/yii2-imagine "~2.0.0"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-совместимого кода:
'aliases' => ['@myext' => '@vendor/mycompany/myext'], // классы myext\Foo → vendor/mycompany/myext/Foo.phpОфициальные расширения#
yii2-debug (панель отладки), yii2-gii (генератор кода), yii2-apidoc (документация API), yii2-faker (тестовые данные).
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.
Своё расширение#
- Репозиторий +
composer.json. Имяvendor/yii2-name, типyii2-extension, зависимость отyiisoft/yii2, автозагрузка PSR-4. - Пространство имён
vendor\name(без префиксаyii2-). Словаyii,yii2,yiisoftв качестве vendor зарезервированы. - Регистрация на Packagist (или в своём репозитории Composer для приватных пакетов), теги версий по semver.
{
"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:
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/yii2require __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. Полный рецепт — в главе руководства «Работа со сторонним кодом».
При установке Composer добавит его в vendor/yiisoft/extensions.php, и Yii узнает о псевдонимах и bootstrap-классе расширения автоматически.
Через класс, реализующий BootstrapInterface, указанный в extra.bootstrap файла composer.json.
Пользователь может держать данные в другом соединении; правильно — объявить настраиваемое свойство db компонента.
Создать new yii\web\Application($config), но не вызывать run() — обработкой запроса занимается хост-система.
🔁 Обработка запроса
Жизненный цикл запроса
Одиннадцать шагов от index.php до ответа, что происходит на этапе предзагрузки и почему bootstrap должен быть лёгким.
Каждый запрос проходит один и тот же путь: входной скрипт → приложение → маршрут → контроллер → фильтры → действие → модель → представление → ответ. Понимая эту цепочку, легко найти, куда встроить свой код: события, фильтры, bootstrap.
Узлы кликабельны — каждый ведёт к своему разделу.
Одиннадцать шагов#
- Пользователь открывает URL — запрос приходит во входной скрипт
web/index.php. - Скрипт загружает конфигурацию и создаёт объект
yii\web\Application. - Приложение разбирает маршрут с помощью компонента
request(иurlManagerпри ЧПУ):post/view+ параметры. - Создаётся контроллер —
PostController, по соглашению об именах или изcontrollerMap. - Создаётся действие и к нему применяются фильтры (
beforeActionприложения → модуля → контроллера). - Любой фильтр не пропустил — обработка останавливается: редирект на вход, 403, 405…
- Все фильтры пройдены — выполняется действие.
- Действие работает с моделью — обычно это запросы к базе через Active Record.
- Действие рендерит представление, передавая ему данные модели; результат оборачивается в шаблон.
- Результат попадает в компонент
response(afterActionв обратном порядке, событиеafterRequest). responseотправляет заголовки и тело браузеру.
Подробнее о шагах: приложение и входной скрипт, маршрутизация, контроллеры, фильтры, запрос и ответ.
Предзагрузка (bootstrapping)#
Всё, что происходит до обработки запроса, — предзагрузка. Она идёт в двух местах:
Во входном скрипте: регистрируются автозагрузчики (Composer, затем Yii через Yii.php), читается конфигурация, создаётся приложение.
В конструкторе приложения:
preInit()— применяются самые важные свойства (basePath);- регистрируется обработчик ошибок;
- применяется остальная конфигурация;
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… |
На этапе фильтров: если один из beforeAction вернул false (нет доступа, неверный HTTP-метод), действие не выполняется.
Подключает расширения из extensions.php, вызывает их bootstrap-классы, создаёт компоненты и модули из свойства bootstrap и вызывает у них bootstrap($app), если реализован интерфейс.
Чтобы ошибки в самой конфигурации и при создании компонентов уже перехватывались и показывались понятно.
До подключения Yii.php — тогда автозагрузчик Yii встанет первым в очереди и будет проверять классы раньше остальных.
Маршрутизация и 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/100 | enablePrettyUrl => true + правила + перезапись на сервере |
'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 или по правилам) он делится по слешам, и приложение идёт по частям:
- Текущий модуль — приложение. Часть ищется в его
controllerMap→ найден контроллер, переход к шагу 4. - Иначе — среди модулей (
modules) → модуль становится текущим, повторяем с шага 1 для следующей части. - Иначе часть — ID контроллера, создаётся по соглашению об именах.
- Следующая часть — ID действия: сначала карта
actions(), затем методactionXxx().
Любой промах — NotFoundHttpException. Пустой маршрут → defaultRoute приложения (site/index), маршрут из одного ID модуля → defaultRoute модуля (default) → defaultAction контроллера (index).
Режим обслуживания — все запросы в одно действие:
'catchAll' => ['site/offline', 'reason' => 'update'],Генерация URL#
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 (или свой класс): шаблон + маршрут + опции. Правила проверяются по порядку до первого совпадения — и при разборе, и при генерации.
'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 и в аргументы действия.
Суффиксы и нормализация#
'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).
Правила из модулей#
Добавлять правила нужно на этапе предзагрузки, иначе они не успеют к разбору запроса:
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:
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;
}
}'rules' => [['class' => 'app\components\CarUrlRule']],Производительность#
- Правила с параметрами в маршруте (
<controller>/<action>) заменяют десятки конкретных. - Частые и узкие правила — выше в списке.
- Правила с общим префиксом — в
GroupUrlRule. - Для REST — готовый
yii\rest\UrlRule(см. REST).
При строгом разборе 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().
Запрос и ответ
Компоненты request и response — параметры, заголовки, URL, доверенные прокси, коды состояния, форматы ответа, редиректы и отправка файлов.
Yii::$app->request — всё о входящем запросе: параметры, метод, заголовки, куки, IP. Yii::$app->response — всё об ответе: код, заголовки, тело, формат. Работайте с ними, а не с $_GET/header(): так код тестируется и не зависит от окружения.
Запрос#
Параметры#
$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):
'request' => [
'parsers' => ['application/json' => 'yii\web\JsonParser'],
],Метод, URL, заголовки, клиент#
$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-* нельзя верить по умолчанию — их подставит любой клиент. Перечислите доверенные прокси:
'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.
Ответ#
Код состояния#
Yii::$app->response->statusCode = 201; // по умолчанию 200
throw new \yii\web\NotFoundHttpException(); // обработчик ошибок сам выставит 404
throw new \yii\web\HttpException(402, 'Payment required');| Исключение | Код |
|---|---|
BadRequestHttpException | 400 |
UnauthorizedHttpException | 401 |
ForbiddenHttpException | 403 |
NotFoundHttpException | 404 |
MethodNotAllowedHttpException | 405 |
NotAcceptableHttpException | 406 |
ConflictHttpException | 409 |
GoneHttpException | 410 |
UnsupportedMediaTypeHttpException | 415 |
TooManyRequestsHttpException | 429 |
ServerErrorHttpException | 500 |
Заголовки#
$headers = Yii::$app->response->headers;
$headers->add('Pragma', 'no-cache'); // добавить (существующие сохраняются)
$headers->set('Pragma', 'no-cache'); // заменить
$headers->remove('Pragma');Имена регистронезависимы; отправятся при send().
Тело и формат#
// готовая строка
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 (например, читаемый вывод в отладке):
'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 из конфигурации.
Перенаправление#
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 обрабатывает его автоматически.
Отправка файлов#
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(), но его можно вызвать и раньше; повторный вызов игнорируется:
- событие
beforeSend; prepare()—dataформатируется вcontent;- событие
afterPrepare; sendHeaders();sendContent();- событие
afterSend.
События удобны, чтобы, например, обернуть все JSON-ответы в единый конверт — см. Ошибки и логирование.
Настроить парсер '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.
Чтобы ответ ушёл немедленно и к нему не добавился вывод последующего кода.
Сессии и куки
Компонент session, хранилища сессий (файлы, БД, кеш, Redis), flash-сообщения, чтение и отправка кук, валидация кук подписью.
Сессии и куки хранят данные между запросами. Yii оборачивает $_SESSION и $_COOKIE в объекты: сессия открывается сама при первом обращении, куки подписываются секретным ключом и не могут быть подделаны на клиенте.
Сессия#
$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\Session | Redis — рекомендуемый вариант, если он есть |
yii\mongodb\Session | MongoDB |
'session' => [
'class' => 'yii\web\DbSession',
// 'db' => 'db', 'sessionTable' => 'session',
],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-сообщения#
Живут ровно до следующего запроса — идеальны для «Сохранено» после редиректа:
// запрос 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.
// чтение
$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. Ключ обязателен:
'request' => [
'cookieValidationKey' => 'длинная-случайная-строка', // не коммитьте в репозиторий
],Отключать enableCookieValidation не рекомендуется. Куки, прочитанные напрямую из $_COOKIE или записанные setcookie(), валидацию не проходят.
Нет — компонент открывает сессию автоматически при первом обращении. Только при прямой работе с $_SESSION нужен open().
ArrayAccess возвращает копию массива; изменение копии не попадает в сессию. Записывайте массив целиком или используйте плоские ключи.
Устанавливается в одном запросе, доступно в следующем, после чего удаляется автоматически.
Подпись не совпадёт, и request->cookies куку не вернёт; в $_COOKIE она по-прежнему есть.
Общее для всех узлов: БД (DbSession) или лучше Redis (yii\redis\Session); файлы не подходят.
Ошибки и логирование
Обработчик ошибок и своя страница ошибки, 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показываются без стека всегда: это «ошибки пользователя».
try {
10 / 0;
} catch (\yii\base\ErrorException $e) {
Yii::warning('Деление на ноль');
}
throw new \yii\web\NotFoundHttpException('Такой страницы нет'); // → 404 с правильным статусомСвоя страница ошибки#
Лучший способ — действие контроллера:
'components' => [
'errorHandler' => [
'errorAction' => 'site/error',
// 'maxSourceLines' => 20, 'errorView', 'exceptionView' — свои шаблоны
],
],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:
'response' => [
'on beforeSend' => function ($event) {
$response = $event->sender;
if ($response->data !== null) {
$response->data = ['success' => $response->isSuccessful, 'data' => $response->data];
$response->statusCode = 200;
}
},
],Логирование#
Запись#
Yii::debug('начало расчёта', __METHOD__); // уровень trace — только при разработке
Yii::info('пользователь вошёл', 'app\auth');
Yii::warning('неожиданное состояние');
Yii::error('не удалось отправить письмо', __METHOD__);Второй аргумент — категория. __METHOD__ даёт app\controllers\SiteController::actionLogin — так категории образуют иерархию, по которой удобно фильтровать (yii\db\*). Сообщение может быть строкой, массивом или объектом (будет экспортирован VarDumper).
Цели#
Компонент log должен быть в bootstrap, чтобы ловить сообщения с самого начала запроса:
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' => 'Ошибки БД',
],
],
],
],
],
];| Цель | Куда |
|---|---|
FileTarget | runtime/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().
Профилирование#
Yii::beginProfile('import');
// код
Yii::beginProfile('parse'); // вложенные пары должны быть сбалансированы
Yii::endProfile('parse');
Yii::endProfile('import');Результаты — сообщения уровня profile; их показывает панель отладчика. Так, например, yii\db\Command замеряет каждый SQL-запрос.
Указать 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.
🧠 Ключевые концепции
Компоненты и свойства
BaseObject и Component — два базовых класса Yii; свойства через геттеры и сеттеры, соглашения о конструкторе и init(), жизненный цикл объекта.
Почти каждый класс Yii наследует yii\base\BaseObject (свойства через геттеры/сеттеры + конфигурация массивом) или yii\base\Component (то же + события и поведения). Понимая эти два класса, вы понимаете, как устроен весь фреймворк.
BaseObject или Component?#
- свойства через
getX()/setX(); - конфигурация через массив
$config; - лёгкий — как обычный PHP-объект.
Для структур данных, значений, хелперов-объектов.
- всё из
BaseObject; - события:
on(),off(),trigger(); - поведения:
behaviors(),attachBehavior(); - чуть дороже по памяти и времени.
Для всего, что «живёт»: компоненты приложения, модели, контроллеры, виджеты.
Свойства через геттеры и сеттеры#
Метод getLabel() создаёт свойство label для чтения, setLabel() — для записи:
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()#
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().
Жизненный цикл объекта#
- Конструктор — предварительная инициализация, значения по умолчанию.
- Применение
$config— свойства из массива перезаписывают умолчания (через сеттеры или публичные поля). init()— постинициализация: проверить готовность, нормализовать.- Работа — вызовы методов.
Первые три шага происходят внутри конструктора, поэтому любой полученный объект уже полностью сконфигурирован.
BaseObject — если нужны только свойства и конфигурация; Component — если объект должен генерировать события или принимать поведения.
InvalidCallException — свойство доступно только для чтения.
Конструктор родителя применяет конфигурацию и вызывает init(); свои умолчания надо задать до этого, а в init() сначала дать отработать родителю, чтобы объект был инициализирован.
$obj->canGetProperty('label') / canSetProperty('label'); property_exists() геттеры не видит.
События
Как подписаться на событие, вызвать своё, передать данные, остановить цепочку обработчиков; события уровня класса, интерфейса и глобальные.
Событие — точка, в которой компонент говорит «случилось вот это», а любой код может отреагировать. Компонент вызывает trigger(), подписчики регистрируются через on(). Так фреймворк даёт встраиваться в свою работу, не требуя наследования.
Подписка#
$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 имяСобытия':
'components' => [
'db' => [
'class' => 'yii\db\Connection',
'on afterOpen' => function ($event) {
$event->sender->createCommand("SET time_zone = '+00:00'")->execute();
},
],
],Порядок и остановка#
Обработчики вызываются в порядке подписки. Чтобы остановить остальные:
$foo->on(Foo::EVENT_HELLO, function ($event) {
$event->handled = true; // следующие обработчики не вызовутся
});Многие события фреймворка отменяют операцию через $event->isValid = false (beforeSave, beforeAction, beforeLogin…).
Вызов своего события#
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.
Отписка#
$foo->off(Foo::EVENT_HELLO, 'function_name');
$foo->off(Foo::EVENT_HELLO, $anonymousFunction); // замыкание нужно сохранить в переменную при подписке
$foo->off(Foo::EVENT_HELLO); // все обработчики событияСобытия уровня класса#
Слушать событие всех экземпляров класса и его потомков — статический Event::on():
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() может быть интерфейс — обработчик получит события всех классов, которые его реализуют:
interface DanceEventInterface { const EVENT_DANCE = 'dance'; }
Event::on(DanceEventInterface::class, DanceEventInterface::EVENT_DANCE, function ($event) {
Yii::debug(get_class($event->sender) . ' танцует');
});Вызвать Event::trigger() для интерфейса нельзя — только для конкретного класса.
Глобальные события#
Никакого отдельного механизма: событие вешают на синглтон — приложение:
Yii::$app->on('app.mail.sent', function ($event) { /* ... */ });
Yii::$app->trigger('app.mail.sent', new Event(['sender' => $mailer]));Пространство имён одно на всё приложение — давайте событиям осмысленные префиксы.
Где события уже есть#
beforeRequest, afterRequest, beforeAction, afterActionbeforeAction, afterActionbeforeValidate, afterValidateinit, afterFind, beforeInsert/Update/Delete, afterInsert/Update/Delete, afterRefreshbeforeRender, afterRender, beginPage, endPage, beginBody, endBodybeforeLogin, afterLogin, beforeLogout, afterLogoutbeforeSend, afterPrepare, afterSendafterOpen, beginTransaction, commitTransaction, rollbackTransactionНаследовать yii\base\Component и вызывать $this->trigger($name, $event).
Установить $event->handled = true в обработчике.
Первый слушает конкретный объект; второй — все экземпляры класса и его потомков (и вызывается после обработчиков экземпляра).
Аргументом $data: третьим у $obj->on() и четвёртым у Event::on(); они доступны как $event->data. Данные, известные в момент события, передают через свой класс Event в trigger().
Ключом 'on имяСобытия' => $handler в массиве конфигурации компонента.
Поведения
Поведения (миксины Yii) добавляют компоненту методы, свойства и реакции на события без наследования; встроенные TimestampBehavior, BlameableBehavior, SluggableBehavior и сравнение с трейтами.
Поведение — объект yii\base\Behavior, «прикреплённый» к компоненту: его публичные методы и свойства становятся методами и свойствами компонента, а events() подписывает его на события владельца. Самый известный пример — TimestampBehavior, который сам проставляет created_at/updated_at.
Как выглядит поведение#
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'] или замыкание.
Прикрепление#
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'],После прикрепления:
$component->prop1 = 'x'; // свойство поведения
$component->foo(); // метод поведения
$component->getBehavior('myBehavior2');
$component->getBehaviors();Если два поведения объявили одноимённый метод, побеждает то, что прикреплено раньше.
Встроенные поведения#
TimestampBehavior#
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'); // проставить текущее время в атрибут и сохранитьДругие#
BlameableBehaviorcreated_by / updated_bySluggableBehavior'attribute' => 'title'), с опциями уникальности и неизменностиAttributeBehaviorAttributeTypecastBehaviorOptimisticLockBehavioryii2tech\ar\softdelete\SoftDeleteBehaviorПоведения против трейтов#
- наследуются, настраиваются конфигурацией;
- прикрепляются и отвязываются на лету, без правки класса;
- умеют слушать события владельца;
- конфликты имён решаются порядком прикрепления.
- быстрее: нет дополнительных объектов;
- поддерживаются IDE как конструкция языка;
- требуют правки класса и ручного разрешения конфликтов.
Они дополняют друг друга: трейт — для чистого переиспользования кода, поведение — когда нужны конфигурация, динамика и события.
Через свойство $this->owner, которое устанавливается при attachBehavior().
Массив «событие владельца → обработчик»; так TimestampBehavior подписывается на beforeInsert/beforeUpdate и заполняет даты.
Это объект: его можно настроить, прикрепить и отвязать во время выполнения, и оно может реагировать на события.
Ключом 'as имя' => [...] в массиве конфигурации компонента.
Конфигурации, псевдонимы, автозагрузка
Формат конфигурации (class, свойства, on, as), Yii::createObject() и Yii::configure(), конфигурационные файлы, константы окружения; псевдонимы путей @app/@web/@runtime; правила автозагрузки классов и карта классов.
В Yii объекты описывают массивами: класс, начальные свойства, обработчики событий, поведения. Такой массив передают в Yii::createObject(), в конструктор или в конфигурацию приложения. Пути и URL в конфигурации пишут псевдонимами (@app/runtime), а классы находятся автозагрузчиком по пространству имён — и всё это связано между собой.
Формат конфигурации#
$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 не нужен — класс уже известен:
echo Menu::widget([
'activateItems' => false,
'items' => [
['label' => 'Home', 'url' => ['site/index']],
['label' => 'Login', 'url' => ['site/login'], 'visible' => Yii::$app->user->isGuest],
],
]);Конфигурация приложения#
Самая большая: свойства приложения плюс components, modules, params. Обычно разбита на файлы, каждый возвращает массив:
$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():
'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:
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.comgetAlias() не проверяет, что путь существует. Корневой псевдоним может содержать / — берётся самое длинное совпадение (@foo/bar приоритетнее @foo).
Задают их обычно в конфигурации: 'aliases' => ['@uploads' => '@webroot/uploads']. Большинство свойств фреймворка принимают псевдонимы напрямую: 'cachePath' => '@runtime/cache'.
Предопределённые#
| Псевдоним | Указывает на |
|---|---|
@yii | папка фреймворка (vendor/yiisoft/yii2) |
@app | basePath приложения |
@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) находит файл по двум правилам:
- класс в пространстве имён:
foo\bar\MyClass; - путь = псевдоним из имени класса:
@foo/bar/MyClass.php— значит,@foo(или@foo/bar) должен быть корневым псевдонимом.
Поэтому app\controllers\SiteController живёт в @app/controllers/SiteController.php, а классы расширений находятся через их псевдонимы. Чтобы подключить библиотеку без автозагрузчика, достаточно объявить псевдоним для её корневого пространства имён.
Yii::$classMap['foo\bar\MyClass'] = '@app/lib/MyClass.php'; // карта классов: без поиска, самый быстрый путьВсе классы самого фреймворка загружаются через карту классов. Автозагрузчик Composer подключается до Yii.php, чтобы автозагрузчик Yii был первым в очереди.
'on событие' => обработчик подписывает на событие объекта, 'as имя' => конфиг прикрепляет поведение.
createObject() идёт через DI-контейнер: применяет зарегистрированные умолчания и разрешает зависимости конструктора.
Путь всё равно вернётся — getAlias() не проверяет существование файлов.
@app — предопределённый псевдоним, а автозагрузчик ищет @app/components/MyClass.php по пространству имён.
Обернуть его подключение в if (YII_ENV_DEV) { ... } в конфигурации.
Service Locator и DI-контейнер
Yii::$app как локатор сервисов, Yii::$container как контейнер внедрения зависимостей: регистрация, разрешение через конструктор и сеттеры, синглтоны, Instance::of(), практические сценарии.
Service Locator (Yii::$app, любой модуль) хранит именованные компоненты и отдаёт их по ID. DI-контейнер (Yii::$container) умеет создавать объекты, сам подставляя зависимости из типов конструктора. Локатор построен поверх контейнера: когда Yii::$app->db создаётся впервые, объект собирает контейнер.
Service Locator#
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() локатора. Для обёрток над сторонними библиотеками удобно прятать сложную инициализацию за статической фабрикой, которая возвращает замыкание:
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-контейнер#
Виды внедрения#
// 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());
});Регистрация#
$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() — один раз.
Разрешение#
$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]); // именованные параметрыРазрешение рекурсивно: контейнер смотрит на типы параметров конструктора и создаёт всю цепочку.
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()#
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.
Практика#
Умолчания для виджетов и любых классов#
Yii::$container->set('yii\widgets\LinkPager', ['maxButtonCount' => 5]);
echo LinkPager::widget(); // 5 кнопок
echo LinkPager::widget(['maxButtonCount' => 20]); // явное значение сильнееРаботает для всего, что создаётся через Yii::createObject() — а это почти всё ядро.
Зависимости в контроллере#
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». Регистрируем реализацию — и контейнер подставит её третьим аргументом:
Yii::$container->set('app\components\BookingInterface', 'app\components\BookingService');Локатор отдаёт компоненты по строковому ID (Yii::$app->db); контейнер создаёт объекты по классу/интерфейсу, автоматически разрешая зависимости конструктора. Локатор использует контейнер для создания своих компонентов.
Когда объект не хранит изменяемого состояния и его дорого создавать — например, обёртка над файловым хранилищем или соединение.
Чтобы в параметрах конструктора сослаться на другую зарегистрированную зависимость по имени — контейнер подставит её при создании.
Ошибка создания: контейнер не знает, какой класс подставить. Нужно Yii::$container->set(Interface::class, Impl::class).
Yii::$container->set('yii\widgets\LinkPager', ['maxButtonCount' => 5]) или container.definitions в конфигурации.
🗄 Базы данных
DAO — работа с 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.
Подключение#
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, MariaDB | mysql:host=localhost;dbname=mydb |
| PostgreSQL | pgsql:host=localhost;port=5432;dbname=mydb |
| SQLite | sqlite:/path/to/database.sqlite |
| MS SQL Server | sqlsrv:Server=localhost;Database=mydb (или dblib:, mssql:) |
| Oracle | oci:dbname=//localhost:1521/mydb |
| через ODBC | odbc:Driver={MySQL};Server=… + 'driverName' => 'mysql' |
Реальное соединение открывается лениво — при первом запросе или open(). Компонентов может быть несколько (db, db2).
Запросы#
$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:
$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#
$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() обязателен.
Экранирование имён и префикс#
$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.
Транзакции#
$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 задают уровень на всё соединение.
Репликация: чтение с реплик, запись в мастер#
'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 секунд.
$db->useMaster(function ($db) { // прочитать именно с мастера
return $db->createCommand('SELECT …')->queryAll();
});
$db->enableSlaves = false; // всё на мастер
$transaction = $db->slave->beginTransaction(); // транзакция на реплике (редко)Схема базы данных#
$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();Эти же методы, но с выводом прогресса, есть у миграций — там им и место.
PDO отдаёт все значения строками ради точности; приводите тип сами или используйте Active Record, который приводит по схеме.
Привязать параметром нельзя; проверьте значение по белому списку допустимых столбцов и оберните в [[...]].
Будет выполнен rollBack(), а исключение проброшено дальше.
На мастер: в рамках транзакции все запросы используют соединение с мастером.
Имя таблицы post с автоматической подстановкой tablePrefix и экранированием под текущую СУБД.
Query Builder
Построение SQL-запросов методами yii\db\Query — select/from/where/join/orderBy/limit, форматы условий (строка, хеш, оператор, объект), filterWhere, агрегаты, пакетная выборка batch/each, подзапросы и Expression.
yii\db\Query собирает запрос объектно и независимо от СУБД: вы описываете что нужно, а построитель сам экранирует имена, привязывает параметры и генерирует SQL под конкретную базу. Тот же API используют ActiveQuery и Active Record, так что выучить его придётся один раз.
Скелет запроса#
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#
$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 — три формата условий#
Строковый#
$query->where('status=1');
$query->where('status=:status', [':status' => $status]); // параметры обязательно через плейсхолдеры
$query->where('YEAR(somedate) = 2015');
$query->where('status=:status')->addParams([':status' => $status]);Хеш — «столбец → значение»#
$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 могут быть вложенными массивами любого формата:
$query->where([
'and',
['status' => 1],
['or', ['type' => 1], ['>', 'created_at', $ts]],
]);Объектный (с 2.0.14)#
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.
Достраивание условий#
$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 >= 100filterWhere — идеальный инструмент для поисковых форм: WHERE username=:u появится, только если поле заполнено.
Остальные части#
$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);Выполнение и результат#
$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() читают курсором порциями:
$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) { }Выражения и подзапросы#
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#
$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();Так можно применить построитель, но выполнить запрос вручную — например, через другое соединение или с дополнительной обработкой.
Хеш или операторный формат с 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.
Active 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 через расширения.
Объявление#
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(), массовое присваивание и валидацию.
Чтение#
// построение через 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();Форма результата#
// массивы вместо объектов — быстрее, но без приведения типов и связей-объектов
$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) { } // связи тоже грузятся по пакетамДоступ к данным#
$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, где всё — строки. Даты остаются строками.
Сохранение#
$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) атрибуты; если их нет — запрос не выполняется вовсе:
$customer->dirtyAttributes; // ['email' => 'new@…']
$customer->oldAttributes; // значения на момент загрузки
$customer->getOldAttribute('email');
$customer->markAttributeDirty('email'); // принудительноСравнение строгое (===): 1 и '1' — разные значения.
Значения по умолчанию, приведение типов, счётчики#
$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) — цена скорости.
Удаление#
$customer = Customer::findOne(123);
$customer->delete(); // DELETE одной записи, с событиями
Customer::deleteAll(['status' => 0]); // массовоЖизненный цикл#
Именно в него встраиваются поведения и переопределённые методы.
new__construct() → init() → событие EVENT_INITfind() / findOne()init() → EVENT_INIT → заполнение атрибутов → afterFind() → EVENT_AFTER_FINDsave()beforeValidate() → EVENT_BEFORE_VALIDATE → валидация → afterValidate() → EVENT_AFTER_VALIDATE → beforeSave() → EVENT_BEFORE_INSERT/UPDATE → INSERT/UPDATE → afterSave() → EVENT_AFTER_INSERT/UPDATEdelete()beforeDelete() → EVENT_BEFORE_DELETE → DELETE → afterDelete() → EVENT_AFTER_DELETErefresh()afterRefresh() → EVENT_AFTER_REFRESHpublic function beforeSave($insert)
{
if (!parent::beforeSave($insert)) {
return false; // отменить сохранение
}
if ($insert) {
$this->token = Yii::$app->security->generateRandomString();
}
return true;
}Возврат false из beforeValidate(), beforeSave(), beforeDelete() отменяет операцию. Не забывайте parent::…() — иначе не сработают события и поведения.
Транзакции#
// вручную — как в 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() — выполняется внутри транзакции.
Оптимистичная блокировка#
Защищает от ситуации «два пользователя редактируют одну запись»:
// 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.
Связи#
Объявление#
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');
}
}Массив соответствий читается как «столбец связанного класса ⇒ столбец текущего». Порядок — сначала чужой столбец — самая частая ошибка.
Доступ#
$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.
Связь через промежуточную таблицу#
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#
// плохо: 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():
$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#
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.
Сохранение связей#
$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#
Общие условия выборки — в один класс:
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() через него не проходят.
Дополнительные поля из запроса#
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').
Столбец customer_id таблицы Order (связанный класс) равен столбцу id текущей модели Customer.
with() делает отдельный запрос для связи (без JOIN) и не позволяет фильтровать по её столбцам; joinWith() добавляет JOIN и позволяет where по связанной таблице.
Не прошла валидация: сохранение отменено, ошибки в $model->errors.
Нет — массовые операции не создают объекты и не вызывают события и поведения.
Атрибуты, изменённые после загрузки; только они попадают в UPDATE. Если изменений нет, save() не выполняет запрос.
Защита от перезаписи чужих правок: при несовпадении версии save() бросит StaleObjectException.
Миграции
Версионирование схемы БД — создание миграции, 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>_<имя> — порядок применения определяется временной меткой.
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:
'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' для MySQLaddColumn / dropColumn / renameColumn / alterColumnaddPrimaryKey / dropPrimaryKeyaddForeignKey($name, $table, $columns, $refTable, $refColumns, $delete, $update) / dropForeignKey'CASCADE', 'SET NULL', 'RESTRICT')createIndex($name, $table, $columns, $unique = false) / dropIndexaddCommentOnColumn / addCommentOnTable / dropCommentFrom…insert / batchInsert / update / delete / upsertexecute($sql, $params)Все они выводят время выполнения и работают через $this->db. Обычные Yii::$app->db->createCommand() тоже доступны, но прогресса не покажут.
$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…)Постоянно — в консольной конфигурации:
'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.
Быстрый рецепт#
./yii migrate/create create_post_table --fields="title:string:notNull,body:text,author_id:integer:notNull:foreignKey(user)"- Проверить сгенерированный файл, поправить типы, добавить индексы.
./yii migrateлокально; закоммитить файл миграции.- На сервере после деплоя:
./yii migrate --interactive=0. - Ошибка после деплоя —
./yii migrate/downвернёт схему назад.
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 остановится с сообщением.
📝 Формы и валидация
Формы и ввод
ActiveForm и ActiveField — поля, списки, шаблоны и подсказки; загрузка файлов через UploadedFile; табличный ввод (loadMultiple/validateMultiple) и несколько моделей в одной форме; JavaScript-API формы (валидация, события, динамические поля).
Форма в Yii — это модель (правила и подписи), виджет ActiveForm (HTML + клиентская валидация) и контроллер (load() → validate() → действие). Всё уже сцеплено: $form->field($model, 'email') сам рисует label, input с именем LoginForm[email], подсказку, ошибку и подключает JS-правила из rules().
Классическая тройка#
class EntryForm extends Model
{
public $name;
public $email;
public function rules()
{
return [
[['name', 'email'], 'required'],
['email', 'email'],
];
}
}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]);
}<?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 — виды полей#
$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]).
Списки: где взять данные#
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 Pjax::begin(['id' => 'pjax-form']) ?>
<?php $form = ActiveForm::begin(['options' => ['data-pjax' => true]]) ?>
…
<?php ActiveForm::end() ?>
<?php Pjax::end() ?>Атрибут data-pjax на форме — и отправка пройдёт без перезагрузки страницы, обновив только контейнер. Внутри Pjax не дублируйте ID формы; ссылки внутри тоже станут pjax-запросами.
Загрузка файлов#
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;
}
}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]);
}<?php $form = ActiveForm::begin() ?>
<?= $form->field($model, 'imageFile')->fileInput() ?> <?php // enctype проставится сам ?>
<button>Загрузить</button>
<?php ActiveForm::end() ?>Несколько файлов#
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 снимает ограничение на количество.
Табличный ввод — много одинаковых моделей#
Редактируем сразу несколько записей (например, все настройки):
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 foreach ($settings as $index => $setting): ?>
<?= $form->field($setting, "[$index]value")->label($setting->name) ?>
<?php endforeach; ?>Имя поля станет Setting[1][value], loadMultiple() разложит их по объектам. Для создания N новых записей — заранее создать N объектов new Setting() (или расширить массив под количество пришедших строк) и то же самое.
Несколько разных моделей в одной форме#
$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]);<?= $form->field($user, 'username') ?>
<?= $form->field($profile, 'website') ?>Разные классы — разные имена форм (User[username], Profile[website]), конфликтов нет. Если классы одинаковые, задайте formName() или используйте индексы, как в табличном вводе.
JavaScript-API ActiveForm#
Виджет подключает yii.activeForm.js и передаёт ему правила из clientValidateAttribute() валидаторов.
Настройка валидации#
enableClientValidationtrue)enableAjaxValidationunique, exist и других серверных правил)validateOnBlur, validateOnChange, validateOnType (+validationDelay), validateOnSubmitvalidationUrlerrorCssClass, successCssClass, validatingCssClass, errorSummaryCssClassОбработчик AJAX-валидации на сервере:
if (Yii::$app->request->isAjax && $model->load(Yii::$app->request->post())) {
Yii::$app->response->format = Response::FORMAT_JSON;
return ActiveForm::validate($model); // {"loginform-email": ["…"]}
}События#
$('#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: [ошибки]}, туда можно дописать свою.
Динамические поля#
$('#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').
Атрибут не «безопасный»: он не упомянут в 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.
Валидация
Правила rules(), сценарии и on/except, пустые значения и skipOnEmpty/skipOnError, условная валидация when/whenClient, фильтрация данных, ad hoc-валидация через DynamicModel, свои валидаторы (inline и класс), клиентская, отложенная и AJAX-валидация.
Данные извне не проверены, пока не прошли validate(). Правила описываются декларативно в rules(), ошибки собираются в модели, а те же правила автоматически превращаются в клиентскую JavaScript-проверку. Это единый механизм для форм, REST-запросов и консольных команд.
rules()#
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'], // во всех, кроме
];
}Валидатор указывается коротким именем (встроенные валидаторы), именем класса, объектом или именем метода модели. Правила применяются по порядку, атрибут проверяется всеми правилами, где он упомянут.
Запуск и ошибки#
$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}…).
Активные правила и атрибуты#
$model->scenario = 'register';
$model->activeAttributes(); // атрибуты, участвующие в сценарии
$model->getActiveValidators(); // валидаторы сценария
$model->getActiveValidators('email');
$model->validators; // ArrayObject всех валидаторов — можно добавить объект на летуПравило активно, если сценарий модели попадает в on (или on не задан) и не попадает в except. Атрибуты активных правил становятся безопасными для load() — кроме тех, что помечены восклицательным знаком в scenarios().
Пустые значения#
По умолчанию валидатор пропускает пустой атрибут (null, '', []) — проверяет только required. Меняется опцией:
['email', 'email', 'skipOnEmpty' => false],
['tags', 'each', 'rule' => ['string'], 'isEmpty' => function ($value) { return $value === [] || $value === null; }],По умолчанию Validator::isEmpty() считает пустыми null, '' и []. Ошибка при первом правиле атрибута приводит к пропуску остальных — skipOnError => false отключает это.
Условная валидация#
['state', 'required', 'when' => function ($model) {
return $model->country == 'USA';
}, 'whenClient' => "function (attribute, value) {
return $('#country').val() == 'USA';
}"],when — серверное условие; whenClient — то же на JS, чтобы клиентская валидация не спорила с серверной.
Фильтрация — не проверка, а преобразование#
[['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:
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 — метод модели#
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-код.
Отдельный класс#
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().
Валидатор с несколькими атрибутами#
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(). Свой:
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) валидация#
Для асинхронных проверок на клиенте (например, размер изображения):
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, сложные проверки):
// форма
$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).
Порядок выполнения на практике#
load()— массовое присваивание безопасных атрибутов сценария.validate()→beforeValidate();false— прерывание.- Для каждого правила по порядку: пропуск пустых (
skipOnEmpty), пропуск с ошибками (skipOnError), условиеwhen, затем проверка или фильтрация. afterValidate(); результат —!hasErrors().
По умолчанию 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-валидацию.
Встроенные валидаторы
Справочник валидаторов ядра — 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.
Обязательность и умолчания#
requiredrequiredValue (ожидать конкретное значение), strict (сравнивать строго). Один из двух проверяющих валидаторов (второй — captcha), который срабатывает и на пустом значении; у default, filter и trim skipOnEmpty тоже выключен, но они не проверяют, а меняют значениеdefaultvalue (значение или замыкание), если атрибут пуст. Не проверяет, а заполняетsafe[['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'],Строки и форматы#
stringmin..max или ровно length; encoding (по умолчанию charset приложения)emailallowName (разрешить Иван <ivan@site.ru>), checkDNS (проверить MX-запись), enableIDNurlvalidSchemes (['http', 'https']), defaultScheme (дописать, если схемы нет), enableIDNipipv4, ipv6, subnet (true — обязательно с маской, null — можно с маской), normalize, ranges (белый/чёрный список: ['10.0.1.0/24', '!10.0.0.0/8', 'any']), negation (разрешить !)matchpattern; not — инвертироватьtrimchars — какие символыfilterfilter (строка, замыкание, [класс, метод]); skipOnArray — не применять к массивам['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 — развернёт сокращённую запись.
Числа и логика#
integermin, max, tooSmall, tooBignumber (double)min/max; integerPattern, numberPatternbooleantrueValue/falseValue (по умолчанию 1/0), strict. Не преобразует значениеcomparecompareAttribute или значением compareValue; operator (==, ===, !=, !==, >, >=, <, <=); type — как сравнивать: string (по умолчанию: оба значения приводятся к строке и сравниваются операторами PHP) или number (приводятся к float)inrange; strict, not, allowArray (проверить каждый элемент массива)['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],Даты#
dateformat (ICU: yyyy-MM-dd, или php:Y-m-d); timestampAttribute — куда записать UNIX-время; timestampAttributeFormat — или в каком формате записать; min/max (+tooSmall/tooBig); timeZone, localedatetime, timetype => 'datetime' / 'time' (короткие имена — с 2.0.10)[['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.
Массивы и вложенность#
eachrule к каждому элементу массива; allowMessageFromRule (сообщение от вложенного правила), stopOnFirstError['categoryIDs', 'each', 'rule' => ['integer']],
['emails', 'each', 'rule' => ['email'], 'allowMessageFromRule' => false, 'message' => 'Один из адресов неверен'],Проверяет только один уровень; для сложных структур — свой валидатор. Не работает с unique/exist через targetAttribute.
Проверки по базе данных#
existtargetClass (по умолчанию — класс модели), targetAttribute (столбец или карта ['a1' => 'b1']), filter (доп. условие), allowArray, targetRelation (по связи AR, 2.0.14)uniquecomboNotUnique (сообщение для составного ключа). Для AR исключает саму запись при обновлении['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-валидацию.
Файлы и изображения#
fileUploadedFile: extensions, mimeTypes (image/*, text/plain), minSize, maxSize, maxFiles (0 — без ограничения), checkExtensionByMimeType (сверять расширение с реальным MIME, по умолчанию true), wrongExtension, tooBig, tooMany…imagefile + проверка изображения: minWidth, maxWidth, minHeight, maxHeight, notImage, underWidth, overWidth…['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#
captchayii\captcha\Captcha; captchaAction (по умолчанию site/captcha), caseSensitive. Как и required, работает с пустым значением — пустое поле сразу даёт ошибку// модель
['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.
Сводка по ключевым опциям#
| Валидатор | Самое важное |
|---|---|
required | requiredValue, strict |
string | min, max, length |
integer, number | min, max |
email, url | checkDNS / defaultScheme, enableIDN |
compare | compareAttribute, operator, type |
in | range, not, allowArray |
date | format, timestampAttribute, min, max |
each | rule |
exist, unique | targetClass, targetAttribute, filter |
file, image | extensions, maxSize, maxFiles, minWidth… |
match | pattern, not |
filter, trim, default | filter / chars / value — меняют значение |
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'].
📊 Вывод данных
Форматирование данных
Компонент 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'), так что один компонент отвечает за весь вывод.
Как вызывать#
$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), локализуется).
Настройка#
'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)asRelativeTime($value, $ref)asDuration($value)Входное значение — UNIX-timestamp, строка (всё, что понимает strtotime(): '2014-01-01', 'now', '+1 day'), DateTime/DateTimeInterface, а с 2.0.1 — и строка вида 2014-01-01 00:00:00.
Форматы#
$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 | Пример |
|---|---|---|
yyyy | Y | 2014 |
MM / MMM / MMMM | m / M / F | 01 / Jan / January |
dd | d | 05 |
EEE / EEEE | D / l | Mon / Monday |
HH / hh a | H / h A | 14 / 02 PM |
mm, ss | i, s | 05, 09 |
Часовые пояса#
Значения без явного пояса считаются заданными в defaultTimeZone (по умолчанию UTC) и переводятся в timeZone при выводе. Храните время в UTC — тогда достаточно выставить timeZone под пользователя. У DateTime со своим поясом преобразование учитывает его.
$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)asScientific($value)asCurrency($value, $currency)asSpellout($value)asOrdinal($value)asShortSize($value, $decimals)asSize() — 12.4 kibibytes; sizeFormatBase (1024 или 1000)asLength($value), asShortLength(), asWeight(), asShortWeight()systemOfUnits (2.0.13)$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)asNtext($value)<br>asParagraphs($value)<p>asHtml($value, $config)asEmail($value, $options)mailto:asUrl($value, $options)<a>asImage($value, $options)<img>asBoolean($value)booleanFormat (['Нет', 'Да'])Где применяется автоматически#
// 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.
$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().
Провайдеры данных, пагинация, сортировка
ActiveDataProvider, SqlDataProvider, ArrayDataProvider и свой провайдер; классы Pagination и Sort — как они читают параметры из URL, строят ссылки и подключаются к запросам; LinkPager и ссылки сортировки вручную.
Провайдер данных — объект DataProviderInterface, который отдаёт страницу моделей с учётом пагинации и сортировки из параметров запроса. Его принимают GridView, ListView, REST-контроллеры. Вы описываете источник (запрос AR, SQL или массив) — остальное делают классы Pagination и Sort.
Три провайдера#
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, можно задать другое.
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.
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 и читает их из запроса:
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, pageSizeParampage, per-page)pageSize, defaultPageSize, pageSizeLimit[1, 50] по умолчанию; pageSizeLimit = false — без ограниченийpagevalidatePage обрезает по pageCount)forcePageParampage=1 даже для первой (по умолчанию true)route, params, urlManagerpageCount, offset, limit, getLinks()createUrl($page), getPageCount()$pages->createUrl(2); // /index.php?r=article%2Findex&page=3
$pages->getLinks(); // ['self' => …, 'first' => …, 'prev' => …, 'next' => …, 'last' => …] — для RESTВ провайдере: 'pagination' => false отключает пагинацию, ['pageSize' => 0] — без LIMIT, но с ссылками.
LinkPager#
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 и ссылки:
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 | nullattributesasc/desc, default, labeldefaultOrderenableMultiSort, separator (,)sortParamsort)route, params, urlManagerordersORDER BY в формате ['col' => SORT_ASC]attributeOrdersСсылки link() получают класс asc/desc по текущему направлению — удобно рисовать стрелки CSS.
Сортировка по связи в ActiveDataProvider#
Провайдер знает только атрибуты модели; связанные столбцы добавляются вручную:
$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 по связям — см. виджеты данных.
Свой провайдер#
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 одного списка не влияли на другой, дайте параметрам разные имена:
$providerA = new ActiveDataProvider([
'query' => Post::find(),
'pagination' => ['pageParam' => 'post-page'],
'sort' => ['sortParam' => 'post-sort'],
]);Или задайте id провайдеру — тогда параметры станут id-page и id-sort автоматически.
По объекту Query провайдер сам строит COUNT(*), а произвольный SQL он переделать не может.
В объекте — с 0, в URL-параметре page — с 1.
Сортировка по created_at по убыванию, затем по title по возрастанию (при enableMultiSort = true).
pageSizeLimit в Pagination (по умолчанию [1, 50]).
Сделать joinWith() в запросе и добавить атрибут в $provider->sort->attributes с asc/desc по столбцу связанной таблицы.
Виджеты данных
DetailView для одной модели, ListView со своим шаблоном элемента, GridView с колонками DataColumn/ActionColumn/CheckboxColumn/SerialColumn, фильтрами, сортировкой и форматами; работа со связями (joinWith), Pjax, несколько таблиц на странице.
Три виджета, которые закрывают 90% вывода данных: DetailView — карточка одной записи, ListView — список произвольной вёрстки, GridView — таблица с сортировкой, фильтрами и пагинацией. Все берут данные из провайдера и форматируют через formatter.
DetailView#
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#
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']],
]);<article>
<h2><?= Html::a(Html::encode($model->title), ['view', 'id' => $model->id]) ?></h2>
<?= Yii::$app->formatter->asDate($model->created_at) ?>
</article>GridView#
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' или массив:
[
'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#
[
'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#
['class' => 'yii\grid\CheckboxColumn', 'checkboxOptions' => function ($model) { return ['value' => $model->id]; }],var keys = $('#grid').yiiGridView('getSelectedRows'); // массив ключей отмеченных строкЧекбокс в заголовке отмечает всё. Имя поля — selection[] (name колонки).
SerialColumn#
Порядковый номер строки с учётом страницы.
Фильтрация и поисковая модель#
Стандартный паттерн (Gii генерирует именно так):
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;
}
}public function actionIndex()
{
$searchModel = new PostSearch();
$dataProvider = $searchModel->search(Yii::$app->request->queryParams);
return $this->render('index', compact('searchModel', 'dataProvider'));
}filterModel рисует поля фильтров по атрибутам с правилами; andFilterWhere игнорирует пустые — фильтр работает «как есть».
Фильтр и сортировка по связи#
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;
}// колонка
['attribute' => 'authorName', 'value' => 'author.name', 'label' => 'Автор'],Для hasMany при joinWith строки дублируются — добавьте ->groupBy('post.id') или используйте ->with() + подзапрос exists. Чтобы оптимизировать, joinWith('author', false) + with('author').
Pjax — таблица без перезагрузки#
<?php Pjax::begin(['id' => 'post-grid']) ?>
<?= GridView::widget([...]) ?>
<?php Pjax::end() ?>Пагинация, сортировка и фильтры внутри работают через AJAX. Кнопки ActionColumn тоже, но после delete нужно обновить: $.pjax.reload({container: '#post-grid'}). Ссылки, которые не должны идти через Pjax, помечайте data-pjax="0".
Несколько GridView на странице#
$userProvider->pagination->pageParam = 'user-page';
$userProvider->sort->sortParam = 'user-sort';
$postProvider->pagination->pageParam = 'post-page';
$postProvider->sort->sortParam = 'post-sort';Иначе страницы и сортировка будут делить одни и те же GET-параметры.
Быстрый рецепт CRUD-списка#
PostSearch extends Postсsearch($params)—ActiveDataProvider+andFilterWhere.- В действии:
$dataProvider = $searchModel->search(Yii::$app->request->queryParams). GridViewсfilterModel, колонками иActionColumn; обернуть вPjax.- Для связей —
joinWith+ виртуальный атрибут +sort->attributes. - Массовые действия —
CheckboxColumn+yiiGridView('getSelectedRows').
Атрибут created_at, формат datetime, заголовок «Создано».
Чтобы получить атрибуты, подписи и типы; rules() переопределяются под фильтр, а search() строит провайдер.
Условие where на столбец другой таблицы требует JOIN; with() делает отдельный запрос и не подходит.
$('#grid').yiiGridView('getSelectedRows') в JS или selection[] из POST.
Пагинация, сортировка и фильтрация обновляют только таблицу без перезагрузки страницы.
🔐 Безопасность
Аутентификация
Компонент 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, тайм-ауты, события) — фреймворка.
Настройка#
'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-класс#
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)findIdentityByAccessToken($token, $type)$type — класс аутентификатораgetId()getAuthKey()validateAuthKey($key)Identity не обязан быть Active Record — подойдёт любой класс (LDAP, внешний API, массив в конфиге).
Вход и выход#
// 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().
Текущий пользователь#
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() вызовется только если к нему обратились.
Доступ только для вошедших#
public function behaviors()
{
return [
'access' => [
'class' => AccessControl::class,
'only' => ['create', 'update', 'delete'],
'rules' => [
['allow' => true, 'roles' => ['@']], // '@' — аутентифицированные, '?' — гости
],
],
];
}Гость получит редирект на loginUrl; после входа goBack() вернёт его туда, откуда он пришёл. Подробнее — в авторизации.
Как это работает под капотом#
- Первый запрос:
user->login()кладётgetId()в сессию (__id) и, при$duration, ставит identity-cookie[id, authKey, duration]. - Следующие запросы:
user->identityчитает ID из сессии →findIdentity($id). - Сессии нет, но есть cookie: сравнивается
authKeyчерезvalidateAuthKey()→ авто-вход, сессия продлевается (autoRenewCookie). authTimeout/absoluteAuthTimeoutпроверяются на каждом запросе; истёк —logout().- REST без сессии (
enableSession = false):findIdentityByAccessToken()вызывает аутентификатор из фильтров (HttpBearerAuthи др.).
События#
'user' => [
'on afterLogin' => function (\yii\web\UserEvent $event) {
$event->identity->updateAttributes(['last_login_at' => time()]);
},
],beforeLogin$event->isValid = false отменяет вход; $event->identity, $event->cookieBased, $event->durationafterLoginbeforeLogout / afterLogoutfindIdentity(), 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().
Авторизация: ACF и RBAC
Фильтр AccessControl с правилами allow/deny по ролям, IP, методам и колбэкам; RBAC — роли, разрешения, иерархия, правила (Rule), PhpManager и DbManager, консольная инициализация, назначение ролей, проверка can(), роли по умолчанию.
Аутентификация сказала, кто пользователь; авторизация решает, что ему можно. Два инструмента: ACF (Access Control Filter) — простые правила у контроллера, и RBAC — управление доступом на основе ролей с иерархией и правилами, когда прав много и они зависят от данных.
Access Control Filter#
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).
Опции правила#
allowtrue разрешить / false запретитьactionscontrollersadmin/user); пусто — текущийroles? гость, @ аутентифицирован, иначе — роль/разрешение RBAC (Yii::$app->user->can())roleParamscan() (массив или замыкание)ips* в конце (192.168.*)verbsGET, POST)matchCallbackfunction ($rule, $action) — любое условиеdenyCallback[
'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]).
Настройка#
'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.
Построение иерархии#
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 через цепочку.
Правила — доступ с учётом данных#
«Автор может редактировать свой пост»:
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;
}
}$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);// проверка
if (Yii::$app->user->can('updatePost', ['post' => $post])) { … }Для автора цепочка author → updateOwnPost(правило) → updatePost — правило проверит createdBy. Для admin путь admin → updatePost без правила — можно всегда.
Назначение ролей#
$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 и правило:
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 пользователя, а не таблицей назначений.
Проверка в коде#
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?#
- права зависят только от «гость/вошёл», IP, метода, действия;
- маленькое приложение, 1–2 роли;
- нужно быстро закрыть контроллер.
- много ролей и разрешений, иерархия;
- доступ зависит от данных (свой/чужой объект);
- права меняются администратором без деплоя (DbManager).
Они совместимы: в roles правила ACF пишут имена RBAC-элементов, и фильтр вызовет can().
Доступ запрещён: гость — редирект на loginUrl, вошедший — ForbiddenHttpException (403).
? — неаутентифицированный пользователь, @ — аутентифицированный.
Разрешение — конкретное действие; роль — набор разрешений и ролей. Проверять лучше разрешения, назначать — роли.
Для условий, зависящих от параметров: например, updateOwnPost проверяет, что автор поста — текущий пользователь.
Через defaultRoles менеджера и правило, определяющее роль по данным пользователя.
Безопасность
Компонент security — хэширование паролей (password_hash/bcrypt), генерация случайных данных, шифрование и подпись (encryptByPassword/encryptByKey, hashData); защита от SQL-инъекций, XSS, CSRF, подделки файлов и утечек конфигурации; практики безопасной настройки сервера и cookie.
Yii закрывает типовые дыры по умолчанию: параметры в SQL, CSRF-токены в формах, подписанные cookie, экранирование в Html::encode(). Ваша часть — не отключать защиту, правильно хранить пароли и не доверять входным данным. Всё криптографическое — в Yii::$app->security.
Пароли#
// при регистрации / смене пароля
$hash = Yii::$app->security->generatePasswordHash($password); // bcrypt, соль внутри — хранить в password_hash
// при входе
if (Yii::$app->security->validatePassword($password, $hash)) { … }Никаких md5()/sha1(): bcrypt медленный намеренно (passwordHashCost, по умолчанию 13). Пароль в открытом виде не хранится и не логируется. Для сброса пароля — случайный токен с ограниченным сроком (generateRandomString() + время).
Случайные данные#
$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() для секретов не годятся.
Шифрование и подпись#
// шифрование по паролю (медленнее: пароль растягивается через 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-инъекции#
// плохо
$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#
<?= 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() ?>.
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. - Логи не должны содержать паролей и токенов.
Cookie и сессии#
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.
Настройка сервера#
// проверка 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
включена; действия, меняющие данные, — только POST/DELETE
YII_DEBUG=false, YII_ENV=prod, errorAction настроен
загрузки вне webroot или без исполнения, имена свои
httpOnly, secure, sameSite; сессии не в /tmp
hostInfo/trustedHosts, HTTPS, актуальные версии зависимостей
Yii::$app->security->generatePasswordHash($password) (bcrypt) в password_hash; проверять validatePassword().
Массив в id превращается в условие; приводите к int или пишите findOne(['id' => $id]).
При выводе любых данных, пришедших от пользователя или из БД, если это не заведомо безопасный HTML.
В beforeAction() контроллера: $this->enableCsrfValidation = false для нужного $action->id.
По ключу — быстрее, ключ должен быть случайным и достаточной длины; по паролю — пароль растягивается через PBKDF2, поэтому медленнее.
⚡ Кеширование
Кэширование данных
Компонент 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, поэтому хранилище можно сменить конфигурацией — код не изменится.
Хранилища#
'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 | файлы | по умолчанию; один сервер |
ApcCache | APCu, память PHP | самый быстрый; сбрасывается при рестарте; не для CLI |
MemCache | memcached | несколько серверов |
yii\redis\Cache | Redis | несколько серверов, персистентность |
DbCache | таблица БД | когда нет ничего другого |
ArrayCache | массив | внутри одного запроса |
DummyCache | нигде | отключить кэш, не меняя код |
WinCache, XCache, ZendDataCache | устаревшие | — |
Можно объявить несколько компонентов (cache, apcCache) — быстрый для мелкого, распределённый для общего.
API#
$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 кэшировать не стоит — неотличимо от промаха; оборачивайте в массив.
Ключи#
Ключ — строка или массив (сериализуется и хэшируется):
$key = [__CLASS__, 'posts', $userId, $page]; // безопасно и читаемо
$cache->set($key, $data);keyPrefix добавляется автоматически, чтобы несколько приложений не мешали друг другу в одном memcached/Redis.
Зависимости#
Кэш инвалидируется не только по времени, но и по условию:
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 у зависимости позволяет пересчитать её один раз за запрос.
Кэширование запросов к БД#
$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() моделиset() с коротким duration->cache() на запросеenableSchemaCacheauthManager.cache), URL-правила (urlManager.cache)false — поэтому не кэшируйте само значение false, а оборачивайте в массив.
Записывать с TagDependency(['tags' => 'posts']) и вызывать TagDependency::invalidate(Yii::$app->cache, 'posts') в afterSave().
Post::find()->where(…)->cache(60)->all().
Чтобы несколько приложений в одном memcached/Redis/APCu не перезаписывали ключи друг друга.
Позволяет писать код с кэшем как обычно, но реально ничего не хранить — например, в dev или тестах.
Кэширование фрагментов, страниц и HTTP
beginCache()/endCache() с duration, dependency, variations и enabled; вложенные фрагменты и динамическое содержимое renderDynamic(); фильтр PageCache; HTTP-кэш через HttpCache — Last-Modified, ETag, Cache-Control, а также session cache limiter и SEO-эффект.
Дальше данных: можно кэшировать готовый HTML — кусок представления, всю страницу, — и даже не отдавать страницу вовсе, если браузер уже имеет свежую копию (HTTP 304). Всё это те же зависимости и то же хранилище, что и в кэшировании данных.
Фрагменты#
<?php if ($this->beginCache($id, $options)) { ?>
...содержимое, которое дорого генерировать...
<?php $this->endCache(); } ?>beginCache() вернул true — кэша нет, содержимое генерируется и сохраняется; false — кэш есть, он уже выведен, тело пропускается.
Опции#
$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', // компонент
];durationdependencyvariationsenabledfalse — просто выводить без кэша (удобно для отладки)cacheВложенные фрагменты#
<?php if ($this->beginCache($id1)) { ?>
...внешний фрагмент...
<?php if ($this->beginCache($id2, $options2)) { ?>
...внутренний...
<?php $this->endCache(); } ?>
...
<?php $this->endCache(); } ?>Внутренний может иметь короче срок, чем внешний, но пока жив внешний — внутренний не пересоберётся (он часть внешнего HTML).
Динамическое содержимое внутри кэша#
<?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 в контроллере — кэш целого ответа, включая заголовки:
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:
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
],
];
}lastModifiedLast-Modified; браузер шлёт If-Modified-SinceetagSeedETag (хэшируется); браузер шлёт If-None-Match. Точнее, чем время, но требует вычислить содержимоеcacheControlHeaderCache-Control (по умолчанию public, max-age=3600)sessionCacheLimitersession_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-токенами в формах.
Всё публичное, что можно отдать браузеру и CDN: лента, API-ответы, картинки. Экономит трафик и время генерации.
Уровни комбинируются: страница закэширована PageCache, а HttpCache перед ним вообще не отдаёт тело, если ETag совпал.
Кэша нет — содержимое нужно сгенерировать; при false оно уже выведено из кэша.
Через $this->renderDynamic() — этот код выполняется при каждом показе.
Чтобы для каждого языка/пользователя/устройства был свой экземпляр кэша, иначе один вариант получат все.
Ответ 304 Not Modified без тела; действие контроллера не выполняется.
PHP ставит Cache-Control: no-store через session cache limiter; задайте sessionCacheLimiter => 'public' или ''.
🔌 REST API
REST 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-ссылками.
Быстрый старт#
namespace app\controllers;
use yii\rest\ActiveController;
class UserController extends ActiveController
{
public $modelClass = 'app\models\User';
}'components' => [
'request' => [
'parsers' => ['application/json' => 'yii\web\JsonParser'], // принимать JSON в теле
],
'urlManager' => [
'enablePrettyUrl' => true,
'enableStrictParsing' => true,
'showScriptName' => false,
'rules' => [
['class' => 'yii\rest\UrlRule', 'controller' => 'user'],
],
],
],Готово. Что получилось:
| Запрос | Действие | Результат |
|---|---|---|
GET /users | index | список постранично, заголовки X-Pagination-*, Link |
HEAD /users | index | только заголовки |
POST /users | create | 201 + созданная модель; при ошибках 422 + список ошибок |
GET /users/123 | view | модель |
PUT, PATCH /users/123 | update | обновлённая модель |
DELETE /users/123 | delete | 204 |
OPTIONS /users | options | разрешённые методы в Allow |
GET /users?fields=id,email&expand=profile | index | выбор полей и связей |
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). Что попадёт в ответ, решают:
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)#
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. Чтобы метаданные попали в тело:
public $serializer = [
'class' => 'yii\rest\Serializer',
'collectionEnvelope' => 'items', // {"items": [...], "_links": {...}, "_meta": {...}}
];Контроллеры#
yii\rest\Controller — базовый, без привязки к модели; ActiveController — с готовыми CRUD-действиями. Оба уже подключают фильтры: ContentNegotiator (формат по Accept), VerbFilter, AuthMethod (если настроен), RateLimiter.
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)ForbiddenHttpExceptioncreateScenario, updateScenarioserializerverbs()VerbFilterСвоё действие — обычный метод с return $data; массив/объект сериализуются автоматически:
public function actionSearch($q)
{
return new ActiveDataProvider(['query' => User::find()->where(['like', 'username', $q])]);
}Маршрутизация#
yii\rest\UrlRule создаёт набор правил под контроллер:
'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 и одного файла:
<?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 — по сути тот же фреймворк, но без лишних папок. Хорошо для маленьких сервисов и чтобы понять, как устроено приложение.
Парсер запроса: 'parsers' => ['application/json' => 'yii\web\JsonParser'] в компоненте request.
Переопределить fields() модели и убрать чувствительные поля; связи вынести в extraFields().
fields() возвращаются всегда (если клиент не сузил ?fields=), extraFields() — только по ?expand=.
В checkAccess($action, $model) контроллера; бросить ForbiddenHttpException.
Опция prefix => 'api' у yii\rest\UrlRule (или префикс модуля в controller).
REST 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.
// 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:
'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:
public $serializer = [
'class' => 'yii\rest\Serializer',
'collectionEnvelope' => 'items', // коллекция внутри {"items": …, "_links": …, "_meta": …}
'fieldsParam' => 'fields', 'expandParam' => 'expand',
'totalCountHeader' => 'X-Pagination-Total-Count', …
];Свои объекты — реализуйте Arrayable или отдавайте массивы.
Аутентификация#
API обычно без сессий: клиент присылает токен в каждом запросе.
'user' => [
'identityClass' => 'app\models\User',
'enableAutoLogin' => false,
'enableSession' => false, // не создавать сессии для API
'loginUrl' => null, // не редиректить — отдавать 401
],use yii\filters\auth\HttpBearerAuth;
public function behaviors()
{
$behaviors = parent::behaviors();
$behaviors['authenticator'] = [
'class' => HttpBearerAuth::class,
// 'except' => ['login', 'options'], // OPTIONS должен быть доступен для CORS
];
return $behaviors;
}public static function findIdentityByAccessToken($token, $type = null)
{
return static::findOne(['access_token' => $token, 'status' => self::STATUS_ACTIVE]);
}HttpBasicAuthAuthorization: Basic; по умолчанию логин = токен, но можно auth — колбэк проверки пароляHttpBearerAuthAuthorization: Bearer <token> — стандарт для APIHttpHeaderAuthheader, pattern), 2.0.14QueryParamAuth?access-token=… — только когда заголовки недоступны (утекает в логи)CompositeAuthauthMethods) по очереди$behaviors['authenticator'] = [
'class' => CompositeAuth::class,
'authMethods' => [HttpBasicAuth::class, HttpBearerAuth::class, QueryParamAuth::class],
];Неудача — 401 Unauthorized. Аутентификатор вызывает Yii::$app->user->loginByAccessToken($token, $type) → findIdentityByAccessToken(); $type — класс аутентификатора, если токены разных видов.
Ограничение частоты (rate limiting)#
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/
...'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/PATCH | 200 |
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 ответа:
'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 — безопасный разбор условий из запроса:
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().
Фильтр 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 с префиксом модуля.
🧰 Инструменты и тесты
Консольные приложения
Входной скрипт 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:
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, чтобы видеть стек ошибок.
Контроллер#
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 поймут результат@param; ./yii help helloДиалог и вывод#
$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 |
cache | flush, 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/ создавались с правильными правами.
Объявить публичное свойство $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.
Тестирование
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.
Модульные тесты#
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).
Функциональные тесты#
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).
Приёмочные тесты#
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 и сервер приложения. Здесь проверяют только ключевые сценарии: регистрация, оплата, поиск.
Фикстуры#
Фикстура приводит БД (или другое состояние) к известному виду перед тестом и убирает после.
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']; // сначала загрузятся зависимости
}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.
Использование#
// в тесте
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 — функциональными тестами
гость не видит, автор может, админ может всё
каждый пойманный баг превращается в тест
Функциональный отправляет запрос в приложение напрямую (без браузера и сервера), приёмочный — через реальный браузер и HTTP.
$I->amLoggedInAs($idOrIdentity) — метод модуля Yii2.
load() только вставляет строки из dataFile. Таблицу опустошает unload() (через resetTable()), а FixtureTrait вызывает выгрузку перед загрузкой — поэтому со стороны кажется, будто чистит load().
Чтобы получать конкретную запись в тесте: $this->tester->grabFixture('users', 'admin').
Свойством depends — зависимости загружаются раньше.
Gii, отладчик и другие инструменты
Генератор кода Gii — установка, allowedIPs, генераторы Model/CRUD/Controller/Form/Module/Extension, консольный режим и свои шаблоны; панель отладки yii2-debug — панели, профилирование, allowedIPs; apidoc, встроенный сервер и полезные команды.
Два расширения, которые в dev-режиме подключены в каждом шаблоне: Gii генерирует модели и CRUD по таблице, Debug показывает запросы, логи, время и память прямо под страницей. Плюс apidoc для документации по PHPDoc и мелочи вроде ./yii serve.
Gii#
Подключение#
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 — генератор пишет файлы в проект.
Генераторы#
tableName(), rules() по типам столбцов, attributeLabels(), связи по внешним ключам (generateRelations), опционально Query-класс и {{%…}}Search-модель + представления index/view/create/update/_form с GridView/DetailView/ActiveForm; опции: виджет списка (GridView/ListView), Pjax, i18n, базовый класс контроллераModule.php, controllers/DefaultController.php, views/default/index.phpcomposer.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)#
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 с категориями и фильтром |
| Profiling | Yii::beginProfile()/endProfile() — время и память по блокам |
| Database | все SQL-запросы с временем; дубли; кнопка EXPLAIN |
| Asset Bundles | какие бандлы и файлы подключены |
отправленные письма (при useFileTransport) | |
| User | identity, роли/разрешения RBAC, переключение пользователя |
| Router | правила urlManager, какое сработало |
| Events, Timeline, Dump | события за запрос, шкала времени, переменные Yii::debug() |
Yii::beginProfile('import');
// ...
Yii::endProfile('import');
Yii::debug($data, 'my.category'); // видно в панели Logs (и Dump)AJAX-запросы тоже попадают в историю. В production модуль не подключают: даже с allowedIPs он раскрывает конфигурацию.
Другие инструменты#
./yii serve --port=8080 --docroot=web./yii help./yii help <cmd> — параметрыvendor/bin/apidoc api src docs/apiyiisoft/yii2-apidoc); apidoc guide — рендер markdown-руководств в стиле yiiframework.com./yii cache/flush-all, ./yii assetYii::getVersion(), phpinfo() в панели Configcomposer outdated, composer show yiisoft/*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').
🧩 Специальные темы
Интернационализация
language и sourceLanguage, Yii::t() с параметрами, множественным числом, select, number и date (ICU MessageFormat), источники переводов PhpMessageSource/DbMessageSource/GettextMessageSource, извлечение сообщений командой message, переводы представлений и модулей, локаль форматтера.
I18n в Yii — это три вещи: язык приложения (Yii::$app->language), перевод сообщений через Yii::t() с форматированием ICU и локализация вывода дат/чисел через formatter. Строки пишутся на исходном языке (обычно английском), переводы хранятся в файлах или БД по категориям.
Языки#
'sourceLanguage' => 'en-US', // на каком языке написаны строки в коде (по умолчанию en-US)
'language' => 'ru-RU', // язык вывода; можно менять во время выполнения// например, по настройке пользователя или заголовку 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()#
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).
Множественное число#
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):
'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}}.
Выбор по значению#
echo Yii::t('app', '{name} is {gender, select, female{she} male{he} other{it}} and loves Yii!', [
'name' => 'Alexander',
'gender' => 'male',
]);Числа, даты, порядковые#
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.
Источники переводов#
'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
],
],
],return [
'This is a string to translate!' => 'Это строка для перевода!',
'Hello, {username}!' => 'Привет, {username}!',
'not translated yet' => '', // пустая — вернётся исходная
];PhpMessageSourcebasePath/<язык>/<категория>.php; самый быстрый и простойDbMessageSourcesource_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'.
Переводы в разных местах#
// модели: подписи и ошибки
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.
Исходную строку 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().
Отправка почты
Компонент mailer (yii2-symfonymailer / swiftmailer) — compose(), setFrom/setTo/setSubject, текст и HTML, представления писем и layout, вложения и встроенные изображения, массовая отправка sendMultiple, транспорт SMTP, файловый транспорт для разработки и тестов.
Yii::$app->mailer — единый интерфейс к почтовой библиотеке. В новых шаблонах это yiisoft/yii2-symfonymailer (Symfony Mailer), в старых — yii2-swiftmailer. Письма собираются из представлений с layout, вложениями и картинками, а в разработке падают в файлы вместо отправки.
Настройка#
'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']. Пароли — вне репозитория.
Отправка#
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С представлением:
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])<?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(), как в обычных.
Вложения и картинки#
$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Массовая отправка#
$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) строил ссылки.
Разработка и тесты#
'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 — те же рендереры представлений.
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 на все письма и возврат числа отправленных.
Производительность
Чек-лист оптимизации — production-режим и opcache, кэш схемы и запросов, оптимизация автозагрузчика Composer, сборка ассетов, сессии в БД/кэше, тяжёлые выборки (asArray, batch, with), логирование и профилирование, где искать узкие места.
Yii быстр по умолчанию, но большинство «медленных сайтов» — это выключенный кэш, включённый debug и N+1 запросов. Ниже — порядок действий: сначала бесплатные переключатели, потом кэширование, потом профилирование и точечные правки.
1. Бесплатные переключатели#
- Production-режим: в
index.phpYII_DEBUG = false,YII_ENV = 'prod'(или не задавать — умолчания именно такие). Отладчик и Gii не подключать. - OPcache:
opcache.enable=1,opcache.validate_timestamps=0(сбрасывать при деплое),opcache.memory_consumption=256. Самый большой выигрыш. - Автозагрузчик:
composer install --no-dev --optimize-autoloader(илиdump-autoload -o --classmap-authoritative). - Кэш схемы БД:
'enableSchemaCache' => true, 'schemaCacheDuration' => 3600вdb— иначе AR читает структуру таблиц каждым запросом. Сбрасывать./yii cache/flush-schemaпосле миграций. - Логи:
traceLevel => 0в production (трассировка стека дорогая),flushInterval/exportIntervalпобольше, только нужныеlevels/categories. - Xdebug выключен на production.
2. Кэширование#
- Данные —
getOrSet()с зависимостями (кэширование данных); хранилище — APCu для одного сервера, Redis/Memcached для нескольких. - Запросы —
Query::cache(),Connection::cache(); схема — выше. - Фрагменты/страницы/HTTP — кэширование вывода.
- RBAC:
authManager.cache, URL-правила:urlManager.cache(по умолчанию используют компонентcache). - Переводы
PhpMessageSourceкэшируются opcache сами;DbMessageSource—cachingDuration.
3. Сессии#
Файловые сессии на одном сервере — нормально; при нескольких серверах или тысячах пользователей:
// или 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. Измерять, а не гадать#
Yii::beginProfile('block');
// …
Yii::endProfile('block');Панель Profiling и Database в отладчике показывают время по блокам и запросам. На production — yii\log\FileTarget с levels => ['profile'] на короткое время, APM (Blackfire, Xhprof, New Relic), медленные запросы в логах СУБД. ab/wrk/k6 для нагрузочного теста до и после изменений.
Чек-лист перед выкладкой#
, debug/gii выключены
включён, validate_timestamps=0
--no-dev -o
и cache не DummyCache/FileCache на нескольких серверах
в БД/Redis при нескольких серверах
проверен в панели Database на ключевых страницах
собраны, appendTimestamp, gzip на сервере
только error/warning, traceLevel=0
enableSchemaCache => true — иначе схема таблицы читается при каждом запросе.
Включаются трассировка, профилирование и подробные логи, что заметно замедляет каждый запрос.
Открыть панель Database отладчика: десятки одинаковых SELECT … WHERE id = ? — признак; лечится with().
Объединяет и минифицирует CSS/JS бандлов по конфигурации в единые файлы для production.
При нескольких веб-серверах (общий доступ) или очень большом числе сессий.
Хелперы
Статические классы 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#
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):
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-классы и стили#
$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"'Формы#
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#
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); // ошибки модели как JSONJsExpression — единственный способ вставить в JSON JS-код (например, колбэк в опции jQuery-плагина).
Url#
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.
Остальные хелперы#
Inflectorpluralize('person') → people, singularize, camelize, camel2id('PostTag') → post-tag, id2camel, humanize, slug('Привет мир') → privet-mir, titleize, classify, tableize, ordinalize(1) → 1stStringHelperbyteLength, byteSubstr, truncate($s, 20, '…'), truncateWords, startsWith, endsWith, explode($s, ',', true, true) (строка первым аргументом; trim + skip empty), basename, dirname, mb_ucfirst, base64UrlEncodeFileHelpernormalizePath, findFiles($dir, ['only' => ['*.php'], 'except' => ['/tests/']]), createDirectory, removeDirectory, copyDirectory, getMimeType, getExtensionsByMimeType, localizeVarDumperdump($var, $depth, $highlight), dumpAsString, export — читаемый вывод любых структурConsoleansiFormat, prompt, confirm, select, прогресс-бары, wrapText, getScreenSizeMarkdownprocess($md, 'gfm'), processParagraphHtmlPurifierprocess($html, $config) — очистка HTMLFormatConverterconvertDateIcuToPhp)IpHelperinRange, expandIPv6, getIpVersion, ip2binBaseObject-хелперы для подмены: class Html extends \yii\helpers\BaseHtml и Yii::$classMap['yii\helpers\Html'] = '@app/components/Html.php'Подмена хелпера: наследуйте от BaseHtml, назовите класс Html в своём пространстве имён и импортируйте его вместо yii\helpers\Html; либо через classMap, чтобы подменить глобально (включая код фреймворка).
ArrayHelper::getValue($user, 'address.city', $default) — работает и с объектами.
map() строит [ключ => значение поля] (для списков), index() — [ключ => целый элемент].
Обернуть в new JsExpression('function () {…}') — Json::encode() вставит её без кавычек.
Без / маршрут относительно текущего контроллера (post/index), с / — абсолютный маршрут от корня приложения, даже внутри модуля.
Html::addCssClass($options, 'new-class').
📌 Справочник
Памятка на одну страницу
Самое нужное из всего руководства в одном месте — команды, конфигурация, контроллер, модель, 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Конфигурация → Конфигурации, Приложение#
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'],
];// любой объект по конфигурации
$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'];Контроллер → Контроллеры, Фильтры, Запрос и ответ#
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]);
}
}// запрос
$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); }Модель и валидация → Модели, Валидация, Валидаторы#
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']];
}
}$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#
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']);
}
}// чтение
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()Представления и формы → Представления, Формы, Виджеты данных#
<?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]) ?><?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() ?>// файл из формы; имя генерируем своё — присланному доверять нельзя
$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 и маршруты → Маршрутизация#
'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',
],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Пользователи и доступ → Аутентификация, Авторизация, Безопасность#
// модель 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();// 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.
Сессии, флеши, cookie → Сессии и cookie#
$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Кэш → Кэширование данных, Кэширование вывода#
$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 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=0public 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#
// события
$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, Почта#
// ./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('Текст'); // сообщение показывается пользователюХелперы → Хелперы, Форматирование#
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);Глоссарий
Ключевые термины Yii 2 в алфавитном порядке с короткими определениями и ссылками на разделы, где о них рассказано подробно.
Короткие определения терминов, которые встречаются по всему руководству. Ссылка ведёт в раздел, где термин разобран.
А–Д#
Active Record (AR)save()/delete(), связи hasOne/hasMany. → Active RecordActiveDataProviderActiveQuery с пагинацией и сортировкой. → Провайдеры данныхActiveForm / ActiveFieldActiveQueryAsset Bundleweb/assets. → АссетыACF (Access Control Filter)AccessControl с правилами allow/deny по ролям, IP, методам. → АвторизацияПсевдоним (alias)Действие (action)Автозагрузкаapp\models\User → @app/models/User.php). → КонфигурацииАутентификацияАвторизацияcan()). → АвторизацияБутстрап (bootstrap)'bootstrap' => ['log'], BootstrapInterface). → Жизненный циклBehavior (поведение)ВалидаторВиджетВходной скриптГрязные атрибуты (dirty)DAODI-контейнерYii::$container: создаёт объекты, разрешая зависимости по типам конструктора. → DIDataProviderЗависимость кэшаЖ–П#
Жадная загрузка (eager)with('rel'): связанные записи одним запросом для всех моделей; против N+1. → Active RecordЛенивая загрузка (lazy)$model->rel: запрос при первом обращении. → Active RecordКомпонентyii\base\Component: свойства, события, поведения. Компонент приложения — Yii::$app->id. → КомпонентыКонфигурацияКонтроллерLayout (шаблон)Массовое присваиваниеМиграцияМодельyii\base\Model: атрибуты, правила, сценарии, подписи, ошибки. → МоделиМодульmodules в конфигурации. → МодулиМаршрут (route)N+1with(). → Active RecordОптимистичная блокировкаStaleObjectException. → Active RecordПагинацияПараметры (params)Yii::$app->params['adminEmail'] — произвольные настройки приложения. → ПриложениеПровайдер данныхПредставление (view)Р–Я#
RBACauthManager. → АвторизацияRESTРасширениеСвязь (relation)Сервис-локаторYii::$app/модуль: хранит компоненты по ID, создаёт лениво. → DIСессияYii::$app->session: данные между запросами; flash-сообщения. → Сессии и cookieСобытиеСценарий (scenario)Тема (theme)pathMap) без изменения кода. → ПредставленияФильтрbeforeAction/afterAction: AccessControl, VerbFilter, PageCache, Cors… → ФильтрыФикстураActiveFixture). → ТестированиеФорматтерФрагментное кэшированиеХелперШаблон приложенияCSRF_csrf в формах и заголовке. → БезопасностьGiiHATEOASIdentityi18nYii::t(), источники переводов, ICU-формат. → i18nPjaxQuery Builderyii\db\Query: объектная сборка SQL под любую СУБД. → Query BuilderResponseService LocatorUrlManagerView (объект)Переход с 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 |
События, свойства, поведения#
// 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 |
темы через themeManager | view.theme с pathMap |
Layout: $content остался; <?php $this->head() ?>, beginBody(), endBody(), beginPage(), endPage() — обязательны для ассетов.
Модели#
| Yii 1.1 | Yii 2 |
|---|---|
CModel, CFormModel | yii\base\Model — и для форм |
$model->attributes = $_POST['Form'] | $model->load(Yii::$app->request->post()) |
safeAttributes()/сценарии через rules | scenarios() + rules() с on; безопасные атрибуты — из активных правил |
CValidator | yii\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_MANY | getOrders() → $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()->format | Yii::$app->formatter |
CCache | yii\caching\*; те же зависимости, getOrSet() |
CConsoleCommand | yii\console\Controller — действия и опции |
CAssetManager::publish() | Asset Bundles — классы с sourcePath, css, js, depends |
Gii, yii-debug-toolbar | yii2-gii, yii2-debug |
CJavaScript::encode | Json::encode + JsExpression |
CDbMigration | yii\db\Migration — safeUp(), migrate/create с шаблонами |
yiilite.php | нет — используйте opcache |
CJSON, CVarDumper | Json, VarDumper |
Yii 1.1 и 2.0 в одном приложении#
Переезжать можно постепенно: старое приложение остаётся на 1.1, новые части пишутся на 2.0. Оба фреймворка используют класс Yii, поэтому нужен один общий, обслуживающий обе версии:
$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;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. Подробности — в разделе расширения и интеграция.
Yii::$app вместо Yii::app().
Статический User::findOne($id); сложные запросы — User::find()->where(...)->all().
Они возвращают результат (return $this->render(...)) вместо echo; результат становится ответом.
Геттерами: getOrders() возвращает $this->hasMany(Order::class, ['customer_id' => 'id']).
Его заменил IdentityInterface, который реализует модель пользователя; проверка пароля — в форме входа.