Старт: установка и первый запуск
Что такое Yii2, установка, структура каталогов, входной скрипт, режимы окружения.
Yii 2 — компонентный full-stack PHP-фреймворк на паттерне MVC. Требует PHP 7.4+ (2.0.x работает и на более новых версиях PHP). Ставится Composer'ом вместе с шаблоном приложения: фреймворк и «скелет» проекта — это один установочный шаг.
Установка за одну команду#
# базовый шаблон — один сайт, самый простой старт
composer create-project --prefer-dist yiisoft/yii2-app-basic basic
# продвинутый шаблон — frontend + backend + common, для команд
composer create-project --prefer-dist yiisoft/yii2-app-advanced advancedПроверка окружения и запуск встроенного сервера:
cd basic
php requirements.php # проверка требований к PHP
php yii serve --port=8080 # http://localhost:8080
php yii # список всех консольных командкорнем веб-сервера должна быть папка web/, а не корень проекта. Иначе конфиги, исходники и runtime/ окажутся доступны из интернета.
Структура каталогов (basic)#
| Путь | Что внутри |
|---|---|
config/web.php | конфигурация веб-приложения |
config/console.php | конфигурация консольного приложения |
config/db.php | параметры подключения к БД |
config/params.php | произвольные параметры (Yii::$app->params) |
controllers/ | контроллеры (SiteController.php) |
models/ | модели, формы, Active Record |
views/ | представления и views/layouts/main.php |
commands/ | консольные команды |
migrations/ | миграции БД |
runtime/ | логи, кеш, временные файлы (нужна запись) |
vendor/ | пакеты Composer, в том числе сам Yii |
web/index.php | единственная точка входа для веба |
web/assets/ | опубликованные CSS/JS-ресурсы |
yii | консольная точка входа (php yii <маршрут>) |
Входной скрипт и константы окружения#
<?php
// web/index.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
$config = require __DIR__ . '/../config/web.php';
(new yii\web\Application($config))->run();| Константа | Значение по умолчанию | Смысл |
|---|---|---|
YII_DEBUG | false | подробные ошибки и стек вызовов, больше логов. Только для разработки |
YII_ENV | 'prod' | окружение; даёт YII_ENV_DEV, YII_ENV_PROD, YII_ENV_TEST |
YII_ENABLE_ERROR_HANDLER | true | включать ли обработчик ошибок Yii |
Консольная точка входа отличается только классом приложения:
$application = new yii\console\Application($config);
exit($application->run()); // код возврата = exit codeНастройка веб-сервера#
Nginx (PHP-FPM):
server {
listen 80;
server_name mysite.test;
root /path/to/basic/web;
index index.php;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
include fastcgi.conf;
fastcgi_pass 127.0.0.1:9000;
}
location ~ /\.(ht|svn|git) { deny all; }
}Apache (.htaccess или конфиг хоста):
DocumentRoot "path/to/basic/web"
<Directory "path/to/basic/web">
RewriteEngine on
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . index.php
</Directory>при nginx + fastcgi поставьте cgi.fix_pathinfo=0 в php.ini, а для HTTPS передавайте fastcgi_param HTTPS on;, иначе Yii не определит защищённое соединение.
Жизненный цикл запроса — коротко#
- Запрос приходит в
web/index.php. - Скрипт грузит конфиг и создаёт объект приложения.
- Компонент
request+urlManagerразбирают URL в маршрутконтроллер/действие. - Создаётся контроллер, затем действие; выполняются фильтры (
beforeAction). - Действие работает с моделью и рендерит представление.
- Результат попадает в компонент
responseи отправляется клиенту.
Минимальный «Hello»: действие + представление#
// controllers/SiteController.php
namespace app\controllers;
use yii\web\Controller;
class SiteController extends Controller
{
public function actionSay($message = 'Привет')
{
return $this->render('say', ['message' => $message]);
}
}// views/site/say.php
<?php use yii\helpers\Html; ?>
<?= Html::encode($message) ?>Открывается как index.php?r=site/say&message=Мир. Правила именования:
| Идентификатор | Класс / метод |
|---|---|
контроллер post-comment | app\controllers\PostCommentController |
контроллер admin/post | app\controllers\admin\PostController |
действие create-comment | public function actionCreateComment() |
Быстрый старт с БД#
// config/db.php
return [
'class' => 'yii\db\Connection',
'dsn' => 'mysql:host=localhost;dbname=yii2basic',
'username' => 'root',
'password' => '',
'charset' => 'utf8mb4',
];// models/Country.php — таблица определяется по имени класса
namespace app\models;
class Country extends \yii\db\ActiveRecord {}$countries = Country::find()->orderBy('name')->all();
$country = Country::findOne('US');
$country->name = 'U.S.A.';
$country->save();Gii — генератор кода#
Gii включён в шаблонах приложений в dev-режиме:
// config/web.php
if (YII_ENV_DEV) {
$config['bootstrap'][] = 'gii';
$config['modules']['gii'] = [
'class' => 'yii\gii\Module',
'allowedIPs' => ['127.0.0.1', '::1', '192.168.0.*'],
];
}Открывается по index.php?r=gii. Умеет генерировать:
- Model Generator — AR-класс по таблице;
- CRUD Generator — контроллер +
SearchModel+ views (index/create/update/view); - генераторы модуля, формы, расширения, контроллера.
Есть и консольный вариант: php yii gii/model --tableName=country --modelClass=Country.
Отладочная панель#
Расширение yiisoft/yii2-debug (в шаблонах уже есть) показывает панель снизу страницы: запросы к БД, логи, профилирование, таймлайн, конфигурацию, письма.
if (YII_ENV_DEV) {
$config['bootstrap'][] = 'debug';
$config['modules']['debug'] = [
'class' => 'yii\debug\Module',
'allowedIPs' => ['127.0.0.1', '::1'],
];
}gii и debug никогда не включаются на продакшене — это прямой доступ к генерации файлов и внутренностям приложения.
Структура приложения: MVC по-Yii-евски
Приложение и его конфиг, компоненты, контроллеры, модели, представления, layout, модули.
Приложение — глобальный объект-одиночка Yii::$app. Он же Service Locator: через него достаются все компоненты (Yii::$app->db, ->user, ->cache, …). Всё приложение описывается одним массивом конфигурации.
Конфигурация приложения#
// config/web.php
return [
'id' => 'basic', // обязателен: уникальный идентификатор
'basePath' => dirname(__DIR__), // обязателен: корень приложения (@app)
'language' => 'ru-RU',
'sourceLanguage' => 'en-US',
'timeZone' => 'Europe/Kyiv',
'defaultRoute' => 'site/index',
'bootstrap' => ['log'], // что грузить при каждом запросе
'aliases' => ['@images' => '@webroot/images'],
'components' => [
'db' => require __DIR__ . '/db.php',
'cache' => ['class' => 'yii\caching\FileCache'],
'user' => [
'identityClass' => 'app\models\User',
'enableAutoLogin' => true,
],
'urlManager' => [
'enablePrettyUrl' => true,
'showScriptName' => false,
'rules' => ['post/<id:\d+>' => 'post/view'],
],
'request' => ['cookieValidationKey' => 'секрет-из-переменной-окружения'],
],
'modules' => ['admin' => ['class' => 'app\modules\admin\Module']],
'params' => require __DIR__ . '/params.php',
];Основные свойства приложения#
| Свойство | Назначение |
|---|---|
id, basePath | обязательные: идентификатор и корень (даёт алиас @app) |
bootstrap | компоненты/модули/классы, создаваемые на каждом запросе |
components | реестр компонентов приложения |
modules | подключённые модули |
params | произвольные параметры: Yii::$app->params['x'] |
controllerNamespace | по умолчанию app\controllers |
controllerMap | ручное сопоставление id => класс контроллера |
defaultRoute | маршрут при пустом запросе (site для веба, help для консоли) |
layout, layoutPath, viewPath | шаблоны и представления |
runtimePath, vendorPath | алиасы @runtime, @vendor |
catchAll | режим обслуживания: ['site/offline'] для всех запросов |
language, sourceLanguage, timeZone, charset | локаль и окружение |
События приложения#
// В конфиге — синтаксис "on <имя события>"
'on beforeRequest' => function ($event) { /* ... */ },
// Или в коде
Yii::$app->on(yii\base\Application::EVENT_BEFORE_ACTION, function ($event) {
if (!$ok) { $event->isValid = false; } // отменить выполнение действия
});EVENT_BEFORE_REQUEST → EVENT_BEFORE_ACTION → действие → EVENT_AFTER_ACTION → EVENT_AFTER_REQUEST. beforeAction поднимается в порядке приложение → модуль → контроллер, afterAction — в обратном.
Компоненты приложения#
Регистрируются в components, создаются лениво — при первом обращении, дальше возвращается тот же экземпляр (синглтон в рамках запроса).
'components' => [
'cache' => 'yii\caching\ApcCache', // просто класс
'db' => ['class' => 'yii\db\Connection', 'dsn' => '...'], // конфиг-массив
'search' => function () { return new app\components\SolrService; }, // фабрика
],Встроенные компоненты, которые есть почти всегда:
| Компонент | Класс | Зачем |
|---|---|---|
request | yii\web\Request | параметры запроса, заголовки, куки |
response | yii\web\Response | код, заголовки, формат, тело ответа |
db | yii\db\Connection | соединение с БД |
user | yii\web\User | аутентификация, identity, can() |
session | yii\web\Session | сессия и flash-сообщения |
urlManager | yii\web\UrlManager | разбор и генерация URL |
view | yii\web\View | рендеринг, регистрация JS/CSS |
assetManager | yii\web\AssetManager | публикация ресурсов |
cache | yii\caching\*Cache | кеш |
log | yii\log\Dispatcher | логи |
errorHandler | yii\web\ErrorHandler | ошибки и исключения |
formatter | yii\i18n\Formatter | форматирование дат, чисел, размеров |
i18n | yii\i18n\I18N | переводы Yii::t() |
mailer | yii\symfonymailer\Mailer | почта |
security | yii\base\Security | хеши, шифрование, токены |
компоненты приложения — это глобальные переменные. Не выносите в них всё: локальный объект, созданный в нужном месте, обычно лучше для тестируемости.
Контроллеры#
namespace app\controllers;
use Yii;
use yii\web\Controller;
use yii\web\NotFoundHttpException;
use app\models\Post;
class PostController extends Controller
{
public $layout = 'post'; // свой layout для всего контроллера
public $defaultAction = 'index'; // действие по умолчанию
public function actions() // «отдельные» действия классами
{
return [
'error' => ['class' => 'yii\web\ErrorAction'],
'captcha' => ['class' => 'yii\captcha\CaptchaAction'],
];
}
public function actionView($id, $version = null) // параметры берутся из $_GET
{
$model = Post::findOne($id);
if ($model === null) {
throw new NotFoundHttpException('Пост не найден.');
}
return $this->render('view', ['model' => $model]);
}
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]);
}
}Что может вернуть действие:
| Возврат | Результат |
|---|---|
| строка | тело ответа (HTML) |
| массив/объект | будет отформатирован согласно response->format (JSON/XML) |
$this->render(...) | представление + layout |
$this->renderPartial(...) | представление без layout |
$this->renderAjax(...) | без layout, но с зарегистрированными JS/CSS |
$this->redirect([...]) | объект Response с заголовком Location |
$this->goHome(), $this->goBack() | редирект на главную / на запомненный URL |
$this->refresh() | перезагрузка текущего URL |
| число (консоль) | exit code (ExitCode::OK) |
Отдельное действие (переиспользуемое):
namespace app\components;
use yii\base\Action;
class HelloWorldAction extends Action
{
public function run() // роль та же, что у actionXxx()
{
return 'Hello World';
}
}Жизненный цикл контроллера#
init()после создания и конфигурирования;- создаётся действие (по
actions()или методуactionXxx), иначеInvalidRouteException; beforeAction()приложения → модуля → контроллера (falseотменяет выполнение);- параметры действия заполняются из запроса, действие выполняется;
afterAction()контроллера → модуля → приложения;- результат передаётся в
response.
контроллеры должны быть тонкими — читать запрос, дёргать модели, отдавать представление. Обработка данных живёт в моделях, разметка — в представлениях.
Модели#
Наследуются от yii\base\Model (форма) или yii\db\ActiveRecord (таблица). Дают: атрибуты, метки, сценарии, правила валидации, массовое присвоение, экспорт в массив.
namespace app\models;
use yii\base\Model;
class ContactForm extends Model
{
public $name;
public $email;
public $body;
public $verifyCode;
public function rules()
{
return [
[['name', 'email', 'body'], 'required'],
['email', 'email'],
['verifyCode', 'captcha'],
];
}
public function attributeLabels()
{
return [
'name' => Yii::t('app', 'Ваше имя'),
'email' => Yii::t('app', 'Email'),
];
}
}$model = new ContactForm();
$model->load(Yii::$app->request->post()); // массовое присвоение по имени класса
if ($model->validate()) { /* ... */ }
$errors = $model->errors; // ['email' => ['Email некорректен.']]
$model->hasErrors('email');
$model->getFirstError('email');Сценарии и безопасные атрибуты#
class User extends ActiveRecord
{
const SCENARIO_LOGIN = 'login';
const SCENARIO_REGISTER = 'register';
public function scenarios()
{
return [
self::SCENARIO_LOGIN => ['username', 'password'],
self::SCENARIO_REGISTER => ['username', 'email', 'password', '!secret'],
];
}
public function rules()
{
return [
[['username', 'email', 'password'], 'required', 'on' => self::SCENARIO_REGISTER],
[['username', 'password'], 'required', 'on' => self::SCENARIO_LOGIN],
];
}
}
$model = new User(['scenario' => User::SCENARIO_LOGIN]);- Массовое присвоение (
load(),$model->attributes = [...]) работает только для «безопасных» атрибутов — перечисленных вscenarios()текущего сценария. - Префикс
!(«!secret») — атрибут валидируется, но массово не присваивается. - Валидатор
safeобъявляет атрибут безопасным без проверки:[['title'], 'safe'].
Экспорт данных#
$array = $model->attributes; // все атрибуты
$array = $model->toArray(); // по fields()
$array = $model->toArray([], ['author', 'tags']); // + extraFields()
public function fields()
{
$fields = parent::fields();
unset($fields['auth_key'], $fields['password_hash']); // убрать секреты
$fields['name'] = fn () => $this->first_name . ' ' . $this->last_name;
return $fields;
}по умолчанию toArray()/REST-ответ отдаёт все атрибуты. Для моделей с паролями и токенами переопределяйте fields().
Представления и layout#
<?php
// views/post/view.php
use yii\helpers\Html;
/** @var yii\web\View $this */
/** @var app\models\Post $model */
$this->title = $model->title;
$this->params['breadcrumbs'][] = ['label' => 'Посты', 'url' => ['index']];
$this->params['breadcrumbs'][] = $this->title;
?>
<h1><?= Html::encode($model->title) ?></h1>
<div><?= \yii\helpers\HtmlPurifier::process($model->text) ?></div>
<?= $this->render('_comments', ['comments' => $model->comments]) ?>| Метод | Где | Что делает |
|---|---|---|
$this->render($view, $params) | контроллер | представление + layout |
$this->renderPartial() | контроллер | без layout |
$this->renderAjax() | контроллер | без layout, с JS/CSS |
$this->renderFile('@app/views/x.php') | везде | по пути/алиасу |
$this->render() | внутри вида/виджета | вложенный вид |
Поиск файла по имени вида: about → @app/views/<controller-id>/about.php, /about → views/ текущего модуля, //site/about → @app/views/site/about.php.
Layout:
<?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() ?>
<?= $content ?>
<?php $this->endBody() ?>
</body>
</html>
<?php $this->endPage() ?>Выбор layout: Controller::$layout → ближайший модуль с заданным layout → приложение. false отключает layout. Вложенные layout — beginContent('@app/views/layouts/base.php') … endContent().
Полезное в представлениях:
$this->title = 'Заголовок';
$this->registerMetaTag(['name' => 'description', 'content' => '...'], 'description');
$this->registerLinkTag(['rel' => 'canonical', 'href' => Url::canonical()]);
$this->registerCss('.red { color: red }');
$this->registerJs('alert(1);', \yii\web\View::POS_READY);
$this->registerJsFile('@web/js/app.js', ['depends' => [\yii\web\YiiAsset::class]]);
// блоки: записать в виде, вывести в layout
$this->beginBlock('sidebar'); echo '...'; $this->endBlock();
echo $this->blocks['sidebar'] ?? '';
// данные между видом и layout
$this->params['breadcrumbs'][] = 'О нас';любой вывод пользовательских данных — через Html::encode(), а HTML от пользователя — через HtmlPurifier::process() (медленно, кешируйте результат).
Модули#
Мини-приложение внутри приложения: свои контроллеры, представления, модели, layout.
modules/admin/
Module.php
controllers/DefaultController.php
models/
views/layouts/ views/default/index.phpnamespace app\modules\admin;
class Module extends \yii\base\Module
{
public $layout = 'admin';
public $defaultRoute = 'default';
public function init()
{
parent::init();
\Yii::configure($this, require __DIR__ . '/config.php');
if (\Yii::$app instanceof \yii\console\Application) {
$this->controllerNamespace = 'app\modules\admin\commands';
}
}
}// подключение
'modules' => ['admin' => ['class' => 'app\modules\admin\Module']],- Маршрут:
admin/default/index; толькоadmin→defaultRoute(default) →defaultAction. - Доступ к модулю:
Yii::$app->getModule('admin'),Module::getInstance(),Yii::$app->controller->module. - Модули вкладываются друг в друга:
forum/admin/dashboard/index. - URL-правила модуля добавляются в
bootstrap()(реализуйтеBootstrapInterface), а не вinit()— иначе они опоздают к разбору запроса.
Виджеты, фильтры и ресурсы
Виджеты, стандартные фильтры (ACF, VerbFilter, CORS, кеш), asset bundles, расширения.
Виджеты#
Виджет — переиспользуемый блок UI: класс + собственные представления + собственные ресурсы.
// вариант 1: всё сразу
<?= \yii\widgets\Menu::widget([
'items' => [
['label' => 'Главная', 'url' => ['site/index']],
['label' => 'Вход', 'url' => ['site/login'], 'visible' => Yii::$app->user->isGuest],
],
]) ?>
// вариант 2: виджет с содержимым между begin() и end()
<?php $form = \yii\widgets\ActiveForm::begin(['id' => 'login-form']); ?>
<?= $form->field($model, 'username') ?>
<?php \yii\widgets\ActiveForm::end(); ?>Свой виджет:
namespace app\components;
use yii\base\Widget;
use yii\helpers\Html;
class HelloWidget extends Widget
{
public $message;
public function init()
{
parent::init();
$this->message ??= 'Hello World';
// ob_start(); // если нужен захват содержимого между begin() и end()
}
public function run()
{
return $this->render('hello', ['message' => $this->message]);
// или: return Html::encode(ob_get_clean());
}
}Представления виджета лежат в <папка класса>/views/. Глобальные значения по умолчанию для виджета задаются через DI-контейнер:
Yii::$container->set('yii\widgets\LinkPager', ['maxButtonCount' => 5]);Встроенные виджеты: ActiveForm, GridView, ListView, DetailView, Menu, Breadcrumbs, LinkPager, LinkSorter, Pjax, Block, ContentDecorator, MaskedInput, Captcha, Spaceless + наборы yii2-bootstrap* и yii2-jui.
Фильтры#
Фильтр — это особый вид поведения, который выполняется до и/или после действия. Объявляется в behaviors() контроллера (или модуля/приложения — тогда действует на всё).
use yii\filters\AccessControl;
use yii\filters\VerbFilter;
public function behaviors()
{
return [
'access' => [
'class' => AccessControl::class,
'only' => ['create', 'update', 'delete'], // или except => [...]
'rules' => [
['allow' => true, 'actions' => ['create'], 'roles' => ['@']],
['allow' => true, 'roles' => ['admin']],
['allow' => true, 'ips' => ['192.168.*'], 'roles' => ['?']],
],
],
'verbs' => [
'class' => VerbFilter::class,
'actions' => [
'index' => ['get'],
'create' => ['get', 'post'],
'update' => ['get', 'put', 'post'],
'delete' => ['post', 'delete'],
],
],
];
}Порядок: пре-фильтры приложения → модуля → контроллера (в порядке behaviors()), затем действие, затем пост-фильтры в обратном порядке. Если пре-фильтр отменил выполнение, дальше ничего не запускается.
Стандартные фильтры#
| Фильтр | Что делает |
|---|---|
AccessControl | правила доступа: roles (? гость, @ авторизован, имя роли RBAC), ips, verbs, matchCallback, denyCallback |
VerbFilter | разрешённые HTTP-методы для действий; иначе 405 |
ContentNegotiator | выбор формата ответа и языка по Accept/GET-параметрам |
HttpCache | Last-Modified / ETag — кеш на стороне клиента |
PageCache | кеш всей страницы на сервере (duration, dependency, variations) |
RateLimiter | ограничение частоты запросов (для REST API) |
Cors | CORS-заголовки; ставится до фильтров аутентификации |
auth\HttpBasicAuth, auth\HttpBearerAuth, auth\QueryParamAuth, auth\CompositeAuth | способы аутентификации (REST) |
Свой фильтр:
namespace app\components;
use yii\base\ActionFilter;
class ActionTimeFilter extends ActionFilter
{
private $_start;
public function beforeAction($action)
{
$this->_start = microtime(true);
return parent::beforeAction($action); // false → действие не выполнится
}
public function afterAction($action, $result)
{
\Yii::debug('Действие ' . $action->uniqueId . ': ' . (microtime(true) - $this->_start));
return parent::afterAction($action, $result);
}
}CORS вместе с родительскими поведениями:
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-Allow-Credentials' => true,
'Access-Control-Max-Age' => 86400,
]],
], parent::behaviors());
}Ресурсы (asset bundles)#
Комплект ресурсов — класс, описывающий набор CSS/JS и их зависимости.
namespace app\assets;
use yii\web\AssetBundle;
use yii\web\View;
class AppAsset extends AssetBundle
{
public $basePath = '@webroot'; // файлы уже в вебруте — публикация не нужна
public $baseUrl = '@web';
// public $sourcePath = '@bower/some-lib'; // файлы вне вебрута → будут опубликованы
public $css = ['css/site.css'];
public $js = ['js/app.js'];
public $jsOptions = ['position' => View::POS_END];
public $cssOptions = ['media' => 'screen'];
public $depends = [
'yii\web\YiiAsset',
'yii\bootstrap5\BootstrapAsset',
];
}Регистрация в представлении/layout:
\app\assets\AppAsset::register($this);| Свойство | Смысл |
|---|---|
sourcePath | исходники вне вебрута → публикуются в @webroot/assets |
basePath / baseUrl | где файлы уже лежат и по какому URL доступны |
css, js | относительные пути или абсолютные URL (CDN) |
depends | другие комплекты; подключаются раньше |
jsOptions, cssOptions | опции для registerJsFile() / registerCssFile() (position, condition, noscript, defer) |
publishOptions | only, except, beforeCopy, forceCopy при публикации |
Настройка и подмена комплектов через assetManager:
'components' => [
'assetManager' => [
'appendTimestamp' => true, // ?v=123 — сброс кеша браузера
'linkAssets' => true, // symlink вместо копирования
'bundles' => [
'yii\web\JqueryAsset' => [ // взять jQuery с CDN
'sourcePath' => null,
'js' => ['//code.jquery.com/jquery-3.7.1.min.js'],
],
'yii\bootstrap5\BootstrapAsset' => false, // отключить комплект
],
'assetMap' => [ // подменить файл во всех комплектах
'jquery.js' => '//code.jquery.com/jquery-3.7.1.min.js',
],
],
],Готовые комплекты ядра: yii\web\YiiAsset (yii.js: data-method, data-confirm, pjax-хелперы), yii\web\JqueryAsset, yii\bootstrap5\BootstrapAsset / BootstrapPluginAsset, yii\jui\JuiAsset.
LESS/SCSS/TypeScript: перечислите css/site.less или js/site.ts — AssetConverter вызовет внешний компилятор (lessc, tsc), команды настраиваются через assetManager.converter.commands.
Объединение и минификация:
yii asset/template assets.php # создать конфиг
yii asset assets.php config/assets-prod.php'assetManager' => [
'bundles' => require __DIR__ . '/' . (YII_ENV_PROD ? 'assets-prod.php' : 'assets-dev.php'),
],@webroot/assets — служебная папка менеджера ресурсов; её содержимое считается временным и может быть удалено. Не указывайте её как sourcePath.
Bower/NPM-пакеты ставятся через Composer (репозиторий https://asset-packagist.org) и доступны по алиасам @bower/<пакет>, @npm/<пакет>.
Расширения#
Расширение — обычный Composer-пакет с "type": "yii2-extension".
composer require yiisoft/yii2-imagine{
"name": "vendor/yii2-mywidget",
"type": "yii2-extension",
"require": { "yiisoft/yii2": "~2.0.0" },
"autoload": { "psr-4": { "vendor\\mywidget\\": "" } },
"extra": { "bootstrap": "vendor\\mywidget\\Bootstrap" }
}autoload.psr-4→ Yii автоматически создаёт алиас (@vendor/mywidget).extra.bootstrap→ класс сBootstrapInterface::bootstrap($app)выполняется на каждом запросе (удобно для регистрации URL-правил, обработчиков событий).- Не используйте
yii,yii2,yiisoftкак имя вендора — они зарезервированы. - В расширении не привязывайтесь к
Yii::$app->db— заводите настраиваемое свойствоdb.
Официальные расширения: yii2-debug, yii2-gii, yii2-bootstrap5, yii2-jui, yii2-imagine, yii2-httpclient, yii2-authclient, yii2-redis, yii2-mongodb, yii2-elasticsearch, yii2-sphinx, yii2-faker, yii2-twig, yii2-smarty, yii2-symfonymailer, yii2-apidoc.
Обработка запроса: роутинг, request, response
Маршрутизация и ЧПУ, Request, Response, сессии и куки, обработка ошибок, логирование.
Маршрутизация и генерация URL#
Маршрут — это [модуль/]контроллер/действие. Разбором и генерацией URL занимается компонент urlManager.
'urlManager' => [
'enablePrettyUrl' => true, // ЧПУ вместо ?r=post/view
'showScriptName' => false, // убрать index.php из URL
'enableStrictParsing' => false, // true → URL обязан совпасть с правилом, иначе 404
'suffix' => '.html', // необязательный суффикс
'normalizer' => [ // слеши в конце: 301-редирект на канонический вид
'class' => 'yii\web\UrlNormalizer',
],
'rules' => [
'posts' => 'post/index',
'post/<id:\d+>' => 'post/view',
'posts/<year:\d{4}>/<category>' => 'post/index',
'<controller:(post|comment)>/<id:\d+>/<action:(create|update|delete)>' => '<controller>/<action>',
'PUT,POST post/<id:\d+>' => 'post/create', // привязка к HTTP-методам
'http://<language:\w+>.example.com/posts' => 'post/index', // имя сервера
['pattern' => 'posts', 'route' => 'post/index', 'suffix' => '.json'],
['pattern' => 'posts/<page:\d+>/<tag>', 'route' => 'post/index',
'defaults' => ['page' => 1, 'tag' => '']], // необязательные параметры
],
],Правила проверяются по порядку, до первого совпадения — частые и узкие правила ставьте выше. Общий префикс удобно группировать через yii\web\GroupUrlRule.
Создание URL#
use yii\helpers\Url;
Url::to(['post/index']); // /index.php?r=post/index
Url::to(['post/view', 'id' => 100]); // с параметром
Url::to(['post/view', 'id' => 1, '#' => 'c']); // с якорем
Url::to(['post/index'], true); // абсолютный URL
Url::to(['post/index'], 'https'); // абсолютный с указанной схемой
Url::toRoute('post/index'); // то же, что to([...]) для маршрута
Url::current(['page' => 2]); // текущий маршрут + изменённые параметры
Url::home(); Url::base(); Url::canonical();
Url::remember(); Url::previous(); // запомнить/вернуть URLКонтекст маршрута: ['index'] — действие текущего контроллера, ['post/index'] — относительно текущего модуля, ['/post/index'] — от корня приложения.
Своё правило URL — класс, реализующий UrlRuleInterface (createUrl(), parseRequest()). Динамическое добавление правил — в bootstrap():
public function bootstrap($app)
{
$app->getUrlManager()->addRules([/* правила модуля */], false);
}Режим обслуживания: 'catchAll' => ['site/offline', 'param' => 'value'].
Request#
$request = Yii::$app->request;
$request->get('id', 1); // $_GET с значением по умолчанию
$request->post('name'); // $_POST
$request->getBodyParam('id'); // тело PUT/PATCH/DELETE (см. parsers)
$request->bodyParams; // все параметры тела
$request->getQueryParams();
$request->method; // GET, POST, ...
$request->isGet; $request->isPost; $request->isPut; $request->isAjax; $request->isPjax;
$request->isSecureConnection;
$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; $request->scriptUrl; $request->serverName; $request->serverPort;
$request->headers->get('Accept');
$request->userAgent; $request->contentType;
$request->acceptableContentTypes; $request->acceptableLanguages;
$request->getPreferredLanguage(['ru-RU', 'en-US']);
$request->userIP; $request->userHost;
$request->getCsrfToken();JSON-запросы (REST):
'request' => [
'parsers' => ['application/json' => 'yii\web\JsonParser'],
],За reverse proxy настраиваются доверенные прокси, иначе IP и схема будут неверными:
'request' => [
'trustedHosts' => ['10.0.2.0/24'],
// при нестандартных заголовках: ipHeaders, secureHeaders, secureProtocolHeaders
],Response#
$response = Yii::$app->response;
$response->statusCode = 201;
$response->headers->set('Pragma', 'no-cache'); // add() не перезаписывает
$response->content = 'hello';
$response->format = yii\web\Response::FORMAT_JSON; // HTML | JSON | JSONP | XML | RAW
$response->data = ['message' => 'hello'];
// Файлы
Yii::$app->response->sendFile('/path/file.pdf');
Yii::$app->response->sendContentAsFile($csv, 'report.csv');
Yii::$app->response->sendStreamAsFile($handle, 'big.zip');
Yii::$app->response->xSendFile('/path/file.zip'); // отдаёт веб-сервер
// Редиректы
return $this->redirect(['post/view', 'id' => 1]);
return $this->redirect('https://example.com/new', 301);
Yii::$app->response->redirect($url)->send(); // вне действия — сразу send()
return $this->goHome(); return $this->goBack(); return $this->refresh();HTTP-исключения (обработчик сам выставит нужный код):
| Исключение | Код |
|---|---|
BadRequestHttpException | 400 |
UnauthorizedHttpException | 401 |
ForbiddenHttpException | 403 |
NotFoundHttpException | 404 |
MethodNotAllowedHttpException | 405 |
NotAcceptableHttpException | 406 |
ConflictHttpException | 409 |
GoneHttpException | 410 |
UnsupportedMediaTypeHttpException | 415 |
TooManyRequestsHttpException | 429 |
ServerErrorHttpException | 500 |
send() вызывается автоматически в конце Application::run(); повторный вызов игнорируется. По пути поднимаются события EVENT_BEFORE_SEND, EVENT_AFTER_PREPARE, EVENT_AFTER_SEND.
Сессии и куки#
$session = Yii::$app->session;
$session->open(); $session->close(); $session->destroy();
$session->isActive;
$session->set('language', 'ru'); // или $session['language'] = 'ru';
$session->get('language', 'en');
$session->remove('language');
$session->has('language');
// Flash-сообщения — живут до следующего запроса
$session->setFlash('success', 'Сохранено');
$session->addFlash('alerts', 'Ещё сообщение'); // накапливает массив
echo $session->getFlash('success');
$session->hasFlash('success');Хранилища сессии: файлы (по умолчанию), yii\web\DbSession, yii\web\CacheSession, yii\redis\Session, yii\mongodb\Session.
нельзя менять элемент массива в сессии напрямую ($session['a']['b'] = 1 не сработает). Пишите массив целиком либо используйте ключи с префиксом captcha.number.
// Чтение кук запроса
$cookies = Yii::$app->request->cookies;
$language = $cookies->getValue('language', 'ru');
// Отправка кук
Yii::$app->response->cookies->add(new \yii\web\Cookie([
'name' => 'language',
'value' => 'ru',
'expire' => time() + 86400 * 30,
'httpOnly' => true,
'secure' => true,
'sameSite' => \yii\web\Cookie::SAME_SITE_LAX,
]));
Yii::$app->response->cookies->remove('language');Куки подписываются секретом request.cookieValidationKey — модифицированная на клиенте кука не попадёт в коллекцию. Прямые $_COOKIE и setcookie() не валидируются.
Обработка ошибок#
Встроенный errorHandler превращает warning/notice в исключения, красиво печатает ошибки в debug-режиме и умеет отдавать ошибку в формате ответа (JSON для API).
'components' => [
'errorHandler' => [
'errorAction' => 'site/error', // действие для показа ошибки
'maxSourceLines' => 20,
],
],// Готовое действие
public function actions()
{
return ['error' => ['class' => 'yii\web\ErrorAction']];
}
// или своё
public function actionError()
{
$exception = Yii::$app->errorHandler->exception;
return $this->render('error', ['exception' => $exception]);
}В представлении error.php доступны $name, $message, $exception. Наследники yii\base\UserException никогда не показывают стек вызовов. При формате ответа JSON ошибка выглядит так: {"name": ..., "message": ..., "code": ..., "status": 404}, а обёртку можно поменять в response.on beforeSend.
Логирование и профилирование#
Yii::debug('сообщение', __METHOD__); // уровень trace
Yii::info('инфо', 'app.payment');
Yii::warning('что-то странное');
Yii::error($exception);
Yii::beginProfile('block1');
// ... измеряемый код (вложенность обязана быть корректной)
Yii::endProfile('block1');'bootstrap' => ['log'], // компонент log должен подниматься сразу
'components' => [
'log' => [
'traceLevel' => YII_DEBUG ? 3 : 0,
'flushInterval' => 1000,
'targets' => [
[
'class' => 'yii\log\FileTarget',
'levels' => ['error', 'warning'],
'categories' => ['yii\db\*', 'app\*'],
'except' => ['yii\web\HttpException:404'],
'logVars' => ['_SERVER'],
'maskVars' => ['_SERVER.HTTP_X_PASSWORD', '_SERVER.*_SECRET'],
'exportInterval' => 1000,
],
[
'class' => 'yii\log\EmailTarget',
'levels' => ['error'],
'message' => ['to' => ['admin@example.com'], 'subject' => 'Ошибка'],
],
],
],
],Цели логов: FileTarget, DbTarget, EmailTarget, SyslogTarget. Уровни: error, warning, info, trace, profile. Категория yii\db\* — маска по префиксу; HTTP-ошибки логируются как yii\web\HttpException:<код>.
в долгоживущих консольных командах ставьте flushInterval и exportInterval в 1 — иначе сообщения не увидите до конца работы. На проде так делать не стоит.
Ключевые концепции Yii
Компоненты, свойства через геттеры, события, поведения, конфигурации, алиасы, автозагрузка, Service Locator, DI.
Это «ядро» фреймворка: если понять эти восемь вещей, остальное читается интуитивно.
Component и BaseObject#
yii\base\BaseObject → свойства (геттеры/сеттеры), init(), конфигурация через массив
yii\base\Component → BaseObject + события + поведенияclass MyClass extends \yii\base\BaseObject
{
public $prop1;
public function __construct($param1, $config = []) // $config — всегда последний
{
// инициализация ДО применения конфигурации
parent::__construct($config); // родитель — в конце
}
public function init()
{
parent::init(); // родитель — в начале
// проверки и нормализация ПОСЛЕ конфигурации
}
}Жизненный цикл: конструктор → применение $config → init() → работа.
если события и поведения не нужны — наследуйтесь от BaseObject: он дешевле по памяти и времени, но свойства через геттеры/сеттеры поддерживает.
Свойства через геттеры и сеттеры#
class Foo extends \yii\base\BaseObject
{
private $_label;
public function getLabel() { return $this->_label; }
public function setLabel($value) { $this->_label = trim($value); }
}
$foo->label = ' abc '; // вызовет setLabel()
echo $foo->label; // вызовет getLabel()- только геттер → свойство только для чтения (запись даёт
InvalidCallException); - имена регистронезависимы (
$obj->label==$obj->Label); - реальное поле класса приоритетнее магического свойства;
property_exists()не видит такие свойства — используйтеcanGetProperty()/canSetProperty();- статические геттеры/сеттеры не работают.
События#
class Mailer extends \yii\base\Component
{
const EVENT_MESSAGE_SENT = 'messageSent';
public function send($message)
{
// ...
$event = new MessageEvent(['message' => $message]);
$this->trigger(self::EVENT_MESSAGE_SENT, $event);
}
}// Подписка: строка-функция, [$obj,'method'], ['Class','method'], анонимка
$mailer->on(Mailer::EVENT_MESSAGE_SENT, function ($event) {
echo $event->name, $event->sender, $event->data;
});
$mailer->on(Mailer::EVENT_MESSAGE_SENT, 'handler', $data, $append = false); // в начало очереди
$mailer->off(Mailer::EVENT_MESSAGE_SENT, $handler); // отписать один
$mailer->off(Mailer::EVENT_MESSAGE_SENT); // отписать всеВ обработчике $event->handled = true прерывает вызов остальных обработчиков.
Уровни подписки:
// в конфигурации
'components' => ['db' => ['on afterOpen' => function ($e) { /* ... */ }]],
// на уровне класса — для всех экземпляров и наследников
Event::on(ActiveRecord::class, ActiveRecord::EVENT_AFTER_INSERT, fn ($e) => /* ... */);
Event::trigger(Foo::class, Foo::EVENT_HELLO); // $event->sender === null
Event::off(Foo::class, Foo::EVENT_HELLO);
// на уровне интерфейса
Event::on('app\interfaces\DanceEventInterface', DanceEventInterface::EVENT_DANCE, $h);
// глобальные события — через синглтон приложения
Yii::$app->on('backend.mail.sent', $handler);
Yii::$app->trigger('backend.mail.sent', new Event(['sender' => $this]));Сначала вызываются обработчики уровня экземпляра, потом — уровня класса.
Поведения (behaviors)#
Поведение «примешивает» свойства и методы к компоненту и может слушать его события — без изменения иерархии наследования.
namespace app\components;
use yii\base\Behavior;
use yii\db\ActiveRecord;
class MyBehavior extends Behavior
{
public $prop1;
public function events()
{
return [ActiveRecord::EVENT_BEFORE_VALIDATE => 'beforeValidate'];
}
public function beforeValidate($event)
{
$this->owner; // компонент, к которому прикреплено поведение
}
public function foo() { /* станет методом компонента */ }
}// статически
public function behaviors()
{
return [
MyBehavior::class, // анонимное
'myBehavior' => ['class' => MyBehavior::class, 'prop1' => 'v'], // именованное
];
}
// динамически
$component->attachBehavior('myBehavior', new MyBehavior());
$component->detachBehavior('myBehavior');
$component->getBehavior('myBehavior');
$component->getBehaviors();
// через конфигурацию
['as myBehavior' => ['class' => MyBehavior::class, 'prop1' => 'v']]При конфликте имён выигрывает поведение, прикреплённое раньше.
Встроенные поведения#
use yii\behaviors\TimestampBehavior;
use yii\behaviors\BlameableBehavior;
use yii\behaviors\SluggableBehavior;
public function behaviors()
{
return [
[
'class' => TimestampBehavior::class,
'attributes' => [
ActiveRecord::EVENT_BEFORE_INSERT => ['created_at', 'updated_at'],
ActiveRecord::EVENT_BEFORE_UPDATE => ['updated_at'],
],
// 'value' => new \yii\db\Expression('NOW()'), // для типа datetime
],
['class' => BlameableBehavior::class, 'createdByAttribute' => 'author_id'],
['class' => SluggableBehavior::class, 'attribute' => 'title', 'ensureUnique' => true],
];
}
$user->touch('login_time'); // TimestampBehavior: обновить поле и сохранитьЕщё есть AttributeBehavior, AttributeTypecastBehavior, OptimisticLockBehavior, CacheableWidgetBehavior.
Поведения vs трейты: поведения поддерживают наследование, настройку, динамическое подключение и подписку на события; трейты быстрее и лучше видны IDE.
Конфигурации#
[
'class' => 'app\components\SearchEngine', // что создавать
'apiKey' => 'xxx', // свойства
'on search' => function ($event) { }, // обработчики событий
'as indexer' => ['class' => IndexerBehavior::class], // поведения
]$object = Yii::createObject($config); // создать по конфигу
Yii::configure($object, $config); // применить к существующему (без ключа class)Значения по умолчанию для всех будущих объектов класса:
Yii::$container->set('yii\widgets\LinkPager', ['maxButtonCount' => 5]);
// или декларативно, в конфиге приложения (с 2.0.11):
'container' => [
'definitions' => ['yii\widgets\LinkPager' => ['maxButtonCount' => 5]],
'singletons' => [/* ... */],
],Конфигурацию разбивают на файлы: web.php подключает db.php, params.php, components.php через require, а окружение различают константами:
if (YII_ENV_DEV) { $config['bootstrap'][] = 'debug'; }Псевдонимы путей (алиасы)#
Yii::setAlias('@images', '@webroot/images');
Yii::setAlias('@cdn', 'https://cdn.example.com');
echo Yii::getAlias('@images/logo.png'); // /var/www/web/images/logo.png| Алиас | Значение |
|---|---|
@yii | папка фреймворка |
@app | basePath приложения |
@runtime | @app/runtime |
@vendor | @app/vendor |
@webroot | папка входного скрипта (web/) |
@web | базовый URL приложения |
@bower, @npm | клиентские пакеты в vendor |
@yii/imagine и пр. | автоматически для каждого расширения |
Производные алиасы получаются добавлением пути: @app/models. Более специфичный корневой алиас выигрывает: при @foo и @foo/bar путь @foo/bar/file.php разрешится через @foo/bar.
Автозагрузка классов#
Автозагрузчик Yii совместим с PSR-4: класс foo\bar\MyClass ищется по алиасу @foo/bar/MyClass.php. Для app\... алиас @app уже определён — свои классы в basic-шаблоне работают «из коробки».
Yii::$classMap['foo\bar\MyClass'] = '@app/legacy/MyClass.php'; // карта классов, самый быстрый путьПорядок в входном скрипте важен: сначала vendor/autoload.php (Composer), затем Yii.php.
Service Locator#
Yii::$app (и каждый модуль) — это yii\di\ServiceLocator: реестр «ID → компонент» с ленивым созданием и кешированием экземпляра.
$locator->set('cache', 'yii\caching\ApcCache');
$locator->set('db', ['class' => 'yii\db\Connection', 'dsn' => '...']);
$locator->set('search', fn () => new app\components\SolrService());
$locator->set('pageCache', new FileCache());
$locator->get('cache'); // == $locator->cache
$locator->has('cache');Массив components в конфиге приложения — это и есть setComponents().
Контейнер внедрения зависимостей (DI)#
Yii::$container умеет разрешать зависимости по типам в конструкторе — рекурсивно.
// Регистрация
Yii::$container->set('yii\mail\MailerInterface', 'yii\symfonymailer\Mailer'); // интерфейс → класс
Yii::$container->set('db', ['class' => 'yii\db\Connection', 'dsn' => '...']); // алиас + конфиг
Yii::$container->set('Foo', fn ($c, $params, $config) => new Foo(new Bar())); // фабрика
Yii::$container->setSingleton('app\storage\FileStorage', [['class' => FileStorage::class], ['/var/tmp']]);
// Получение
$obj = Yii::$container->get('app\components\SearchEngine', [$apiKey], ['type' => 1]);
Yii::$container->invoke([$obj, 'doSomething'], ['param1' => 42]); // внедрение в методВиды внедрения: через конструктор (по типам параметров), через метод (invoke()), через сеттер/свойство (конфигурация), через PHP-callback.
Зависимости в контроллере разрешаются автоматически:
class HotelController extends \yii\web\Controller
{
public function __construct($id, $module, BookingInterface $booking, $config = [])
{
$this->booking = $booking;
parent::__construct($id, $module, $config);
}
}
// нужно лишь сказать контейнеру, какая реализация используется:
Yii::$container->set(BookingInterface::class, BookingService::class);Instance::of('alias') в параметрах ссылается на другую зарегистрированную зависимость. set() создаёт объект каждый раз, setSingleton() — один раз.
регистрируйте зависимости как можно раньше — в конфигурации приложения или в bootstrap-классе расширения. Yii::createObject() внутри использует контейнер, поэтому переопределение классов ядра работает глобально.
Работа с БД: DAO и построитель запросов
Connection, Command, транзакции, репликация, схема БД, построитель запросов.
Три уровня работы с базой: DAO (чистый SQL поверх PDO) → Query Builder (массивы вместо SQL) → Active Record (объекты). Все три можно смешивать.
Подключение#
'db' => [
'class' => 'yii\db\Connection',
'dsn' => 'mysql:host=localhost;dbname=example',
'username' => 'root',
'password' => '',
'charset' => 'utf8mb4',
'tablePrefix' => 'tbl_',
'enableSchemaCache' => true, // кеш схемы — обязателен на продакшене
'schemaCacheDuration' => 3600,
'schemaCache' => 'cache',
'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/db.sqlite |
| MS SQL | sqlsrv:Server=localhost;Database=mydb |
| Oracle | oci:dbname=//localhost:1521/mydb |
| ODBC | odbc:Driver={MySQL};... + обязательно driverName |
Соединение открывается лениво — при первом запросе или open().
Command: SQL напрямую#
$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();
$rows = $db->createCommand('UPDATE post SET status=1 WHERE id=1')->execute(); // кол-во строкПривязка параметров — всегда#
$post = $db->createCommand('SELECT * FROM post WHERE id=:id AND status=:s')
->bindValue(':id', $id)
->bindValue(':s', 1)
->queryOne();
// или сразу
$db->createCommand($sql, [':id' => $id, ':s' => 1])->queryOne();
// bindParam() привязывает по ссылке — удобно в циклах (запрос готовится один раз)
$command = $db->createCommand('SELECT * FROM post WHERE id=:id')->bindParam(':id', $id);
foreach ([1, 2, 3] as $id) { $rows[] = $command->queryOne(); }Готовые CRUD-команды#
$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()->delete('user', ['status' => 0])->execute();
$db->createCommand()->upsert('user', ['email' => 'a@b.c', 'hits' => 1], ['hits' => new Expression('hits + 1')])->execute();
$db->getLastInsertID();Экранирование имён#
// [[столбец]] и {{таблица}}; {{%таблица}} подставляет tablePrefix
$db->createCommand("SELECT COUNT([[id]]) FROM {{%employee}}")->queryScalar();Транзакции#
// краткая форма
Yii::$app->db->transaction(function ($db) {
$db->createCommand($sql1)->execute();
$db->createCommand($sql2)->execute();
}, \yii\db\Transaction::REPEATABLE_READ);
// полный контроль
$transaction = Yii::$app->db->beginTransaction();
try {
// ...
$transaction->commit();
} catch (\Throwable $e) {
$transaction->rollBack();
throw $e;
}Уровни изоляции: READ_UNCOMMITTED, READ_COMMITTED, REPEATABLE_READ, SERIALIZABLE. Вложенные транзакции работают через SAVEPOINT, если СУБД их поддерживает.
SQLite поддерживает только READ UNCOMMITTED и SERIALIZABLE; PostgreSQL не даёт задать уровень до старта транзакции — используйте setIsolationLevel() после старта.
Репликация: master/slave#
[
'class' => 'yii\db\Connection',
'dsn' => 'мастер',
'username' => 'master', 'password' => '',
'slaveConfig' => ['username' => 'slave', 'password' => '',
'attributes' => [PDO::ATTR_TIMEOUT => 10]],
'slaves' => [
['dsn' => 'слейв 1'],
['dsn' => 'слейв 2'],
],
// либо несколько мастеров: 'masters' => [...], 'masterConfig' => [...]
]execute() идёт на мастер, queryXxx() — на случайный живой слейв (есть failover и кеш статуса серверов). Транзакции всегда на мастере. Принудительно:
$rows = Yii::$app->db->useMaster(fn ($db) => $db->createCommand($sql)->queryAll());
Yii::$app->db->enableSlaves = false;Схема БД#
$db->createCommand()->createTable('post', ['id' => 'pk', 'title' => 'string', 'text' => 'text'])->execute();Методы Command: createTable, renameTable, dropTable, truncateTable, addColumn, renameColumn, alterColumn, dropColumn, addPrimaryKey, dropPrimaryKey, addForeignKey, dropForeignKey, createIndex, dropIndex, addCommentOnColumn.
$table = Yii::$app->db->getTableSchema('post'); // yii\db\TableSchema
$table->columns['title']->type;
$table->primaryKey;
Yii::$app->db->schema->getTableNames();Построитель запросов#
use yii\db\Query;
$rows = (new Query())
->select(['id', 'email'])
->from('user')
->where(['last_name' => 'Smith'])
->limit(10)
->all();SELECT / FROM#
$query->select(['user_id' => 'user.id', 'email']); // алиасы через ключи
$query->select(["CONCAT(first_name,' ',last_name) AS full_name"]);
$query->addSelect(['created_at'])->distinct();
$query->from(['u' => 'public.user', 'p' => 'public.post']);
$sub = (new Query())->select('COUNT(*)')->from('user');
$query->select(['id', 'count' => $sub])->from('post'); // подзапрос в SELECT
$query->from(['u' => $sub]); // подзапрос во FROMWHERE — три формата#
// 1. строка (только с привязкой параметров!)
$query->where('status=:status', [':status' => $status]);
// 2. хеш — равенства через AND, корректно обрабатывает null и массивы
$query->where(['status' => 10, 'type' => null, 'id' => [4, 8, 15]]);
// WHERE (status=10) AND (type IS NULL) AND (id IN (4,8,15))
// 3. операторы
$query->where(['and', ['status' => 1], ['or', ['>', 'age', 30], ['type' => 2]]]);| Оператор | Пример | SQL |
|---|---|---|
and, or | ['or', 'id=1', 'id=2'] | id=1 OR 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); второй операнд может быть Query |
like / or like / not like | ['like', 'name', 'test'] | name LIKE '%test%' |
exists / not exists | ['exists', $subQuery] | EXISTS (...) |
>, <, >=, <=, != | ['>', 'age', 10] | age>10 |
$query->andWhere(['like', 'title', $search])->orWhere(['author_id' => 1]);
// filterWhere игнорирует пустые значения (null, '', [], пробелы) — для форм поиска
$query->filterWhere(['username' => $username, 'email' => $email])
->andFilterWhere(['like', 'name', $name]);значения всегда параметризуются, а имена столбцов — нет. Никогда не подставляйте имя столбца из пользовательского ввода без белого списка: $query->where([$request->get('column') => $value]) — SQL-инъекция.
Остальные части запроса#
$query->orderBy(['id' => SORT_ASC, 'name' => SORT_DESC])->addOrderBy('age');
$query->groupBy(['id', 'status'])->addGroupBy('age');
$query->having(['status' => 1])->andHaving(['>', 'age', 30]);
$query->limit(10)->offset(20);
$query->join('LEFT JOIN', 'post', 'post.user_id = user.id');
$query->innerJoin('post', 'post.user_id = user.id');
$query->leftJoin(['u' => $subQuery], 'u.id = author_id');
$query->union($otherQuery);
$query->withQuery($cteQuery, 'cte'); // CTE, с 2.0.35в условии ON нельзя использовать хеш-формат — ['user.id' => 'comment.user_id'] сравнит столбец со строкой. Пишите строкой: 'user.id = comment.user_id'.
Выполнение#
$query->all(); // массив строк
$query->one(); // первая строка (LIMIT не добавляется — ставьте limit(1) сами)
$query->column(); // первый столбец
$query->scalar(); // одно значение
$query->exists(); // bool
$query->count(); $query->sum('x'); $query->average('x'); $query->max('x'); $query->min('x');
$query->indexBy('id')->all(); // индексация результата
$query->indexBy(fn ($row) => $row['id'] . $row['username'])->all();
foreach ($query->batch(100) as $rows) { } // пакетами по 100
foreach ($query->each(100) as $row) { } // по одной строке, память экономится
// Посмотреть SQL
$command = $query->createCommand();
echo $command->getRawSql(); // с подставленными параметрами
echo $command->sql; print_r($command->params);любой Query можно выполнить на другом соединении — передайте его в метод выборки: $query->all($db2).
Active Record
Поиск, сохранение, события, транзакции, оптимистичная блокировка, связи, жадная загрузка.
Класс = таблица, объект = строка, атрибут = столбец. AR наследует yii\base\Model, поэтому умеет всё, что умеют модели: валидацию, сценарии, метки, toArray().
Объявление класса#
namespace app\models;
use yii\db\ActiveRecord;
class Customer extends ActiveRecord
{
const STATUS_ACTIVE = 1;
public static function tableName() // по умолчанию: camel2id(имя класса)
{
return '{{%customer}}'; // % → подставится tablePrefix
}
public static function getDb() // другое соединение
{
return \Yii::$app->db2;
}
}Поиск#
Customer::findOne(123); // по первичному ключу
Customer::findOne(['id' => 123, 'status' => 1]); // по условию-хешу
Customer::findAll([100, 101, 123]); // несколько PK
Customer::find()->where(['status' => 1])->orderBy('id')->all();
Customer::find()->where(['status' => 1])->count();
Customer::find()->indexBy('id')->all();
Customer::find()->asArray()->all(); // массивы вместо объектов — экономнее
Customer::findBySql('SELECT * FROM customer WHERE status=:s', [':s' => 1])->all();
Customer::find()->limit(1)->one(); // one() сам LIMIT не добавляет!
foreach (Customer::find()->batch(100) as $customers) { } // пакетная выборка
foreach (Customer::find()->each(100) as $customer) { }findOne($_GET['id']) небезопасен — пользователь может передать массив и поиск пойдёт по другому столбцу. Пишите findOne(['id' => Yii::$app->request->get('id')]) либо получайте $id как параметр действия (он гарантированно скаляр).
Сохранение и удаление#
$customer = new Customer();
$customer->name = 'James';
$customer->save(); // insert + валидация
$customer->save(false); // без валидации
$customer->insert(); $customer->update(); // явно
$customer = Customer::findOne(123);
$customer->email = 'new@example.com';
$customer->save(); // update только «грязных» атрибутов
$customer->attributes = ['name' => 'X']; // массовое присвоение (только безопасные атрибуты)
$customer->loadDefaultValues(); // значения DEFAULT из схемы БД
$post->updateCounters(['view_count' => 1]); // UPDATE ... SET view_count = view_count + 1
Customer::updateAll(['status' => 1], ['like', 'email', '@example.com']);
Customer::updateAllCounters(['age' => 1]);
$customer->delete();
Customer::deleteAll(['status' => 0]); // осторожно: без условия удалит всёПолезные состояния:
$customer->isNewRecord;
$customer->getDirtyAttributes(); $customer->markAttributeDirty('name');
$customer->getOldAttributes(); $customer->getOldAttribute('name');
$customer->refresh();
$customer->getPrimaryKey(); Customer::primaryKey();updateAll(), deleteAll(), updateCounters(), updateAllCounters() работают одним SQL-запросом и не запускают валидацию и события жизненного цикла.
Жизненный цикл и события#
| Операция | Последовательность |
|---|---|
new | конструктор → init() → EVENT_INIT |
| выборка | конструктор → init() → afterFind() / EVENT_AFTER_FIND |
save() | beforeValidate → валидация → afterValidate → beforeSave (EVENT_BEFORE_INSERT/UPDATE) → запись → afterSave (EVENT_AFTER_INSERT/UPDATE) |
delete() | beforeDelete → удаление → afterDelete |
public function beforeSave($insert)
{
if (!parent::beforeSave($insert)) { return false; } // false → сохранение отменяется
$this->slug = \yii\helpers\Inflector::slug($this->title);
return true;
}Транзакции в AR#
Customer::getDb()->transaction(function ($db) use ($customer) {
$customer->save();
// ...
});Либо декларативно — операции по сценариям:
public function transactions()
{
return [
'admin' => self::OP_INSERT,
'api' => self::OP_ALL, // OP_INSERT | OP_UPDATE | OP_DELETE
];
}Оптимистичная блокировка#
- Столбец
version(BIGINT DEFAULT 0) в таблице. public function optimisticLock() { return 'version'; }- Скрытое поле
versionв форме (Html::activeHiddenInput($model, 'version')). - Ловите
yii\db\StaleObjectExceptionв действии обновления.
Приведение типов#
При заполнении из БД значения приводятся к типам PHP по схеме (int, float, bool), но: decimal/numeric остаются строкой (точность), unsigned bigint — строкой всегда, а обычный bigint и unsigned int — строкой на 32-битных системах. Данные из HTTP-запроса не приводятся. Для этого есть yii\behaviors\AttributeTypecastBehavior. JSON-столбцы (MySQL/PostgreSQL) декодируются автоматически, массивы PostgreSQL — в ArrayExpression.
Связи#
class Customer extends ActiveRecord
{
public function getOrders() // hasMany: ['чужой_столбец' => 'свой_столбец']
{
return $this->hasMany(Order::class, ['customer_id' => 'id'])->inverseOf('customer');
}
public function getCountry()
{
return $this->hasOne(Country::class, ['id' => 'country_id']);
}
public function getBigOrders($threshold = 100) // связь с параметром
{
return $this->hasMany(Order::class, ['customer_id' => 'id'])
->where(['>', 'subtotal', $threshold]);
}
}Many-to-many через промежуточную таблицу:
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'); }Доступ:
$customer->orders; // массив Order — запрос выполняется один раз и кешируется
$customer->getOrders(); // объект ActiveQuery — можно донастроить
$customer->getOrders()->where(['>', 'subtotal', 200])->all();
unset($customer->orders); // сбросить кеш связиимя связи relation зарезервировано — не используйте getRelation().
Отложенная vs жадная загрузка (проблема N+1)#
// 101 запрос — плохо
$customers = Customer::find()->limit(100)->all();
foreach ($customers as $c) { $c->orders; }
// 2 запроса — хорошо
$customers = Customer::find()->with('orders')->limit(100)->all();
// вложенные связи и настройка запроса на лету
Customer::find()->with([
'country',
'orders' => fn ($q) => $q->andWhere(['status' => Order::STATUS_ACTIVE]),
'orders.items',
])->all();При жадной загрузке N связей, из которых M через промежуточные таблицы, выполняется N+M+1 запросов.
если используете select() вместе с with(), обязательно включайте столбцы связи — иначе связь не заполнится: Order::find()->select(['id', 'amount', 'customer_id'])->with('customer').
JOIN по связям#
// фильтр по связной таблице + жадная загрузка
Customer::find()->joinWith('orders')->where(['order.status' => 1])->all();
Customer::find()->innerJoinWith('orders')->all();
Customer::find()->joinWith('orders', false)->all(); // без жадной загрузки
// псевдоним связной таблицы (с 2.0.7)
$query->joinWith(['orders o'])->orderBy('o.id');
$query->joinWith(['orders o' => fn ($q) => $q->joinWith('product p')])->where('o.amount > 100');
// условие в ON, а не в WHERE: вернутся ВСЕ покупатели, но только активные заказы
Customer::find()->joinWith([
'orders' => fn ($q) => $q->onCondition(['order.status' => Order::STATUS_ACTIVE]),
])->all();joinWith() по умолчанию делает LEFT JOIN и включает жадную загрузку. Связи между разными БД (даже MySQL + MongoDB) работают, но joinWith() — только внутри одной СУБД.
Сохранение связей#
$order->link('customer', $customer); // проставит FK и сохранит
$order->link('items', $item); // вставит строку в промежуточную таблицу
$customer->unlink('orders', $order); // FK → null
$customer->unlink('orders', $order, true); // удалить связную запись/строку связи
$customer->unlinkAll('orders', true);link() не валидирует данные и не работает для двух новых (несохранённых) объектов.
Свой класс запроса#
class Comment extends ActiveRecord
{
public static function find()
{
return new CommentQuery(get_called_class());
}
}
class CommentQuery extends \yii\db\ActiveQuery
{
public function active($state = true)
{
return $this->andWhere(['active' => $state]); // именно andWhere, не where
}
}
Comment::find()->active()->all();
Customer::find()->with(['comments' => fn ($q) => $q->active()])->all();Это замена «скоупов» из Yii 1.1. Дополнительные вычисляемые атрибуты:
class Room extends ActiveRecord
{
public $volume;
}
Room::find()
->select(['{{room}}.*', '([[length]] * [[width]] * [[height]]) AS volume'])
->orderBy('volume DESC')
->all();Миграции баз данных
Создание, применение и откат миграций, генераторы, типы столбцов, несколько БД.
Миграция — PHP-класс с методами up()/down() (или транзакционными safeUp()/safeDown()), лежащий в системе контроля версий рядом с кодом. Применённые миграции регистрируются в таблице migration.
Команды#
yii migrate/create create_news_table # создать миграцию
yii migrate # применить все новые
yii migrate 3 # применить 3 следующие
yii migrate/to 150101_185401 # применить/откатить до конкретной миграции
yii migrate/down # откатить последнюю
yii migrate/down 3 # откатить 3 последние
yii migrate/redo # откатить и применить заново
yii migrate/history all # применённые миграции
yii migrate/new all # ещё не применённые
yii migrate/mark 150101_185401 # отметить применённой, ничего не выполняя
yii migrate/fresh # очистить БД и применить всё заново (не для прода!)
yii help migrateОпции: --interactive=0 (для CI), --migrationPath=@app/migrations, --migrationTable=migration, --db=db2, --templateFile=....
Анатомия миграции#
use yii\db\Migration;
class m150101_185401_create_news_table extends Migration
{
public function safeUp() // safeUp/safeDown выполняются в транзакции
{
$this->createTable('{{%news}}', [
'id' => $this->primaryKey(),
'title' => $this->string(255)->notNull(),
'content' => $this->text(),
'author_id' => $this->integer()->notNull(),
'status' => $this->smallInteger()->defaultValue(1),
'created_at' => $this->integer()->notNull(),
], $this->db->driverName === 'mysql' ? 'ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' : null);
$this->createIndex('idx-news-author_id', '{{%news}}', 'author_id');
$this->addForeignKey('fk-news-author_id', '{{%news}}', 'author_id', '{{%user}}', 'id', 'CASCADE');
$this->insert('{{%news}}', ['title' => 'Первая новость', 'created_at' => time()]);
}
public function safeDown() // обратный порядок операций
{
$this->dropForeignKey('fk-news-author_id', '{{%news}}');
$this->dropTable('{{%news}}');
}
}Если миграция необратима — верните false в down().
Методы миграции#
execute, insert, batchInsert, upsert, update, delete, createTable, renameTable, dropTable, truncateTable, addColumn, renameColumn, alterColumn, dropColumn, addPrimaryKey, dropPrimaryKey, addForeignKey, dropForeignKey, createIndex, dropIndex, addCommentOnTable, addCommentOnColumn. Соединение доступно как $this->db.
Типы столбцов (SchemaBuilderTrait)#
$this->primaryKey() $this->bigPrimaryKey()
$this->string(64) $this->text() $this->char(2)
$this->integer() $this->bigInteger() $this->smallInteger() $this->tinyInteger()
$this->decimal(10, 2) $this->float() $this->double() $this->money()
$this->boolean() $this->binary() $this->json()
$this->date() $this->time() $this->dateTime() $this->timestamp()
// модификаторы — цепочкой
$this->string(12)->notNull()->unique()->defaultValue('x')->comment('Комментарий')->after('id')Абстрактные типы вместо varchar(255) делают миграцию независимой от СУБД. Старый стиль через константы yii\db\Schema::TYPE_PK, TYPE_STRING тоже работает.
Генераторы миграций#
Имя миграции задаёт содержимое: create_xxx_table, drop_xxx_table, add_xxx_column_to_yyy_table, drop_xxx_column_from_yyy_table, create_junction_table_for_xxx_and_yyy_tables.
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:foreignKey,title:string,body:text"
yii migrate/create add_position_column_to_post_table --fields=position:integer
yii migrate/create create_junction_table_for_post_and_tag_tablesПервичный ключ id добавляется сам (--fields=name:primaryKey меняет имя). Ключевое слово foreignKey(таблица) создаёт индекс + внешний ключ с CASCADE; его позиция в описании поля не важна.
Несколько БД и отдельные пути миграций#
// config/console.php
'controllerMap' => [
'migrate' => [
'class' => 'yii\console\controllers\MigrateController',
'migrationPath' => null, // отключить путь по умолчанию
'migrationNamespaces' => [
'app\migrations',
'module\migrations',
'some\extension\migrations',
],
'migrationTable' => '{{%migration}}',
],
],yii migrate --db=db2 # мигрировать другую базу
yii migrate --migrationPath=@app/migrations/shop # отдельный набор миграцийне используйте в миграциях классы Active Record. Бизнес-логика меняется, а миграция должна выполняться одинаково всегда — работайте через $this->insert(), $this->update() и Query Builder.
миграции годятся не только для схемы — ими удобно заполнять справочники, строить иерархию RBAC и чистить кеш после изменения структуры.
Формы и проверка данных
ActiveForm, поля ввода, все встроенные валидаторы, свои валидаторы, AJAX-валидация, загрузка файлов.
ActiveForm#
<?php
use yii\helpers\Html;
use yii\widgets\ActiveForm;
$form = ActiveForm::begin([
'id' => 'login-form',
'options' => ['class' => 'form-horizontal', 'enctype' => 'multipart/form-data'],
'enableClientValidation' => true,
'enableAjaxValidation' => false,
'validateOnBlur' => true,
'fieldConfig' => ['template' => "{label}\n{input}\n{hint}\n{error}"],
]) ?>
<?= $form->field($model, 'username')->textInput(['maxlength' => true, 'autofocus' => true]) ?>
<?= $form->field($model, 'password')->passwordInput() ?>
<?= $form->field($model, 'email')->input('email')->hint('Например, user@example.com') ?>
<?= $form->field($model, 'rememberMe')->checkbox() ?>
<?= $form->field($model, 'about')->textarea(['rows' => 6]) ?>
<?= $form->field($model, 'category_id')->dropDownList($items, ['prompt' => 'Выберите…']) ?>
<?= $form->field($model, 'tags[]')->checkboxList($items) ?>
<?= $form->field($model, 'type')->radioList($items) ?>
<?= $form->field($model, 'file')->fileInput() ?>
<?= $form->field($model, 'code')->widget(\yii\captcha\Captcha::class) ?>
<?= $form->field($model, 'name')->label('Ваше имя')->error(['class' => 'help-block']) ?>
<?= $form->errorSummary($model) ?>
<?= Html::submitButton('Войти', ['class' => 'btn btn-primary']) ?>
<?php ActiveForm::end() ?>Имя поля строится как ИмяМоделиБезПространстваИмён[атрибут], поэтому на сервере данные приходят как $_POST['LoginForm']['username'], а $model->load(Yii::$app->request->post()) всё раскладывает сам. Переопределив formName() в модели на пустую строку, получите «плоские» имена (удобно для фильтров в GridView и красивых URL).
Множественные значения — добавьте [] к имени атрибута:
echo $form->field($model, 'uploadFile[]')->fileInput(['multiple' => true]);
echo $form->field($model, 'items[]')->checkboxList(['a' => 'A', 'b' => 'B']);для Bootstrap используйте yii\bootstrap5\ActiveForm — он расставит нужные CSS-классы. Для обновления части страницы оберните форму в Pjax и добавьте 'options' => ['data' => ['pjax' => true]].
Правила валидации#
public function rules()
{
return [
[['name', 'email'], 'required'],
['email', 'email'],
['age', 'integer', 'min' => 18, 'max' => 120],
['username', 'string', 'length' => [4, 24]],
['username', 'unique', 'targetClass' => User::class],
['password_repeat', 'compare', 'compareAttribute' => 'password'],
['status', 'in', 'range' => array_keys(self::statuses())],
[['created_at'], 'safe'],
// только в сценарии, с условием и своим сообщением
['state', 'required', 'on' => self::SCENARIO_REGISTER,
'message' => 'Укажите регион',
'when' => fn ($model) => $model->country === 'USA',
'whenClient' => "function (attribute, value) { return $('#country').val() == 'USA'; }"],
];
}Формат правила: [атрибуты, валидатор, on => сценарии, except => сценарии, свойства…]. Правило применяется, если атрибут активен в текущем сценарии. Правилам можно давать ключи-имена, чтобы наследники могли их убрать (unset($rules['password'])).
$model->validate(); // все активные атрибуты
$model->validate(['email']); // только указанные
$model->hasErrors(); $model->errors; $model->getFirstErrors();
$model->addError('email', 'Занят');
$model->clearErrors();События: beforeValidate() / EVENT_BEFORE_VALIDATE (вернуть false — отменить), afterValidate() / EVENT_AFTER_VALIDATE.
Встроенные валидаторы#
| Псевдоним | Что проверяет / делает | Ключевые свойства |
|---|---|---|
required | значение не пусто | requiredValue, strict |
safe | помечает атрибут безопасным (без проверки) | — |
boolean | равно trueValue/falseValue | trueValue, falseValue, strict |
string | строка нужной длины | length, min, max, encoding |
integer | целое число | min, max |
number, double | число (float) | min, max |
match | соответствие регулярке | pattern, not |
in | входит в список | range, strict, not, allowArray |
compare | сравнение с атрибутом/значением | compareAttribute, compareValue, operator, type |
date, datetime, time | формат даты/времени | format, timestampAttribute, min, max |
email | allowName, checkDNS, enableIDN | |
url | URL | validSchemes, defaultScheme, enableIDN |
ip | IPv4/IPv6/подсеть | ipv4, ipv6, subnet, ranges, normalize, expandIPv6, negation |
unique | уникальность в таблице (AR) | targetClass, targetAttribute, filter |
exist | значение существует в таблице (AR) | targetClass, targetAttribute, filter, allowArray |
file | загруженный файл | extensions, mimeTypes, minSize, maxSize, maxFiles, checkExtensionByMimeType |
image | файл-изображение (+ всё от file) | minWidth, maxWidth, minHeight, maxHeight |
captcha | совпадение с кодом CAPTCHA | caseSensitive, captchaAction |
default | подставляет значение, если пусто | value (значение или callback) |
filter | преобразует значение | filter (callable), skipOnArray |
trim | обрезает пробелы | — |
each | применяет правило к каждому элементу массива | rule, allowMessageFromRule |
// самые частые комбинации
[['username', 'email'], 'trim'],
[['from_date', 'to_date'], 'default', 'value' => null],
['phone', 'filter', 'filter' => fn ($v) => preg_replace('/\D/', '', $v)],
['tags', 'each', 'rule' => ['integer']],
['email', 'unique', 'targetClass' => User::class, 'filter' => ['status' => 10]],
['category_id', 'exist', 'targetClass' => Category::class, 'targetAttribute' => 'id'],
['age', 'compare', 'compareValue' => 30, 'operator' => '>=', 'type' => 'number'],большинство валидаторов пропускают пустые значения (skipOnEmpty = true). Пустые значения обрабатывают только captcha, default, filter, required, trim. Порядок важен: сначала trim/default, потом проверки.
Свои валидаторы#
Встроенный (inline) — метод модели или анонимная функция:
public function rules()
{
return [
['country', 'validateCountry'],
['token', function ($attribute, $params, $validator) {
if (!ctype_alnum($this->$attribute)) {
$this->addError($attribute, 'Только буквы и цифры.');
}
}, 'skipOnEmpty' => false, 'skipOnError' => false],
];
}
public function validateCountry($attribute, $params)
{
if (!in_array($this->$attribute, ['USA', 'Indonesia'], true)) {
$this->addError($attribute, 'Недопустимая страна.');
}
}Автономный — класс-наследник yii\validators\Validator:
namespace app\components;
use yii\validators\Validator;
class CountryValidator extends Validator
{
public function init()
{
parent::init();
$this->message = $this->message ?? 'Недопустимая страна.';
}
public function validateAttribute($model, $attribute)
{
if (!in_array($model->$attribute, ['USA', 'Indonesia'], true)) {
$this->addError($model, $attribute, $this->message);
}
}
// для проверки значения без модели
protected function validateValue($value)
{
return in_array($value, ['USA', 'Indonesia'], true) ? null : [$this->message, []];
}
// клиентская часть
public function clientValidateAttribute($model, $attribute, $view)
{
$message = json_encode($this->message, JSON_UNESCAPED_UNICODE);
return "if ($.inArray(value, ['USA','Indonesia']) === -1) { messages.push($message); }";
}
}В клиентском JS доступны attribute, value, messages, deferred (для асинхронных проверок: deferred.add(function (def) { … def.resolve(); })).
Валидация без модели#
// один валидатор
$validator = new \yii\validators\EmailValidator();
if ($validator->validate($email, $error)) { /* ok */ } else { echo $error; }
// набор правил «на лету»
$model = \yii\base\DynamicModel::validateData(compact('name', 'email'), [
[['name', 'email'], 'string', 'max' => 128],
['email', 'email'],
]);
if ($model->hasErrors()) { /* ... */ }AJAX-валидация#
// представление
$form = ActiveForm::begin(['id' => 'registration-form', 'enableAjaxValidation' => true]);
echo $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);
}Клиентская валидация отключается через enableClientValidation => false (у формы или у отдельного поля).
клиентская валидация — только для удобства пользователя. Серверная validate() обязательна всегда.
Загрузка файлов#
class UploadForm extends \yii\base\Model
{
/** @var \yii\web\UploadedFile[] */
public $imageFiles;
public function rules()
{
return [[['imageFiles'], 'file', 'skipOnEmpty' => false,
'extensions' => 'png, jpg', 'maxFiles' => 4]];
}
public function upload()
{
if (!$this->validate()) { return false; }
foreach ($this->imageFiles as $file) {
$file->saveAs('uploads/' . $file->baseName . '.' . $file->extension);
}
return true;
}
}// представление
<?= $form->field($model, 'imageFiles[]')->fileInput(['multiple' => true, 'accept' => 'image/*']) ?>// контроллер
$model->imageFiles = UploadedFile::getInstances($model, 'imageFiles'); // getInstance() для одногоActiveForm с 2.0.8 сам добавляет enctype="multipart/form-data", если в форме есть fileInput(). Для AR-моделей файл обычно не сохраняют в атрибут напрямую — валидируйте через отдельную форму либо через UploadedFile::getInstance($model, 'file') в сценарии.
Несколько моделей и табличный ввод#
// одна форма — несколько разных моделей
if ($user->load(Yii::$app->request->post()) && $profile->load(Yii::$app->request->post())
&& Model::validateMultiple([$user, $profile])) {
// сохраняем в транзакции
}// табличный ввод: массив однотипных моделей
$models = Item::findAll($ids);
if (Model::loadMultiple($models, Yii::$app->request->post())
&& Model::validateMultiple($models)) {
foreach ($models as $model) { $model->save(false); }
}// в представлении — индексы в именах полей
foreach ($models as $i => $model) {
echo $form->field($model, "[$i]name");
}Вывод данных
Форматтер, пагинация, сортировка, провайдеры данных, GridView/ListView/DetailView, JS/CSS, темы.
Форматтер#
$f = Yii::$app->formatter;
$f->asDate('2014-01-01', 'long'); // 1 января 2014 г.
$f->asDate('now', 'php:d.m.Y'); // через формат date()
$f->asDatetime($ts); $f->asTime($ts); $f->asTimestamp($date);
$f->asRelativeTime($ts); // «час назад»
$f->asDuration(3720); // «1 час 2 минуты»
$f->asInteger(42); $f->asDecimal(2542.123); $f->asPercent(0.125, 2);
$f->asCurrency(420, 'EUR'); $f->asScientific(42000);
$f->asSize(1024 * 410); $f->asShortSize(1024 * 410);
$f->asText($str); $f->asNtext($str); $f->asParagraphs($str);
$f->asHtml($str); $f->asRaw($str);
$f->asEmail($m); $f->asUrl($u); $f->asImage($src); $f->asBoolean(true);
$f->format($value, 'date'); // по имени формата
$f->format(0.125, ['percent', 2]); // формат с параметрами
$f->asDate(null); // «(не задано)» — nullDisplay'formatter' => [
'dateFormat' => 'dd.MM.yyyy',
'datetimeFormat' => 'dd.MM.yyyy HH:mm',
'decimalSeparator' => ',',
'thousandSeparator' => ' ',
'currencyCode' => 'UAH',
'nullDisplay' => '—',
'locale' => 'ru-RU',
'timeZone' => 'Europe/Kyiv',
],Локализация работает при установленном расширении intl; иначе форматы будут «английскими» и без локализованных названий месяцев. Форматы short/medium/long/full зависят от локали, кастомные — либо синтаксис ICU, либо php: + формат date().
храните дату/время в UTC (лучше всего UNIX-timestamp), а часовой пояс задавайте в приложении/форматтере — тогда одни и те же данные корректно покажутся любому пользователю.
Пагинация и сортировка#
$query = Article::find()->where(['status' => 1]);
$pages = new \yii\data\Pagination([
'totalCount' => $query->count(),
'pageSize' => 20,
'pageSizeParam' => false, // не читать per-page из URL
]);
$models = $query->offset($pages->offset)->limit($pages->limit)->all();echo \yii\widgets\LinkPager::widget(['pagination' => $pages, 'maxButtonCount' => 5]);$sort = new \yii\data\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],
]);
$articles = Article::find()->orderBy($sort->orders)->all();
echo $sort->link('name') . ' | ' . $sort->link('age');Направление сортировки читается из GET-параметра sort (настраивается через sortParam). В запрос передавайте $sort->orders, а не attributeOrders — составные атрибуты иначе не раскроются.
Провайдеры данных#
use yii\data\ActiveDataProvider;
$provider = new ActiveDataProvider([
'query' => Post::find()->where(['status' => 1]),
'pagination' => ['pageSize' => 20],
'sort' => ['defaultOrder' => ['created_at' => SORT_DESC]],
'key' => 'slug', // или функция: fn ($model) => md5($model->id)
]);
$provider->getModels(); $provider->getKeys();
$provider->getCount(); $provider->getTotalCount();| Класс | Источник |
|---|---|
ActiveDataProvider | yii\db\Query или ActiveQuery (AR-объекты или массивы) |
SqlDataProvider | сырой SQL + обязательный totalCount |
ArrayDataProvider | готовый массив (allModels) — всё грузится в память |
Свой провайдер — наследник yii\data\BaseDataProvider с методами prepareModels(), prepareKeys(), prepareTotalCount().
GridView#
use yii\grid\GridView;
echo GridView::widget([
'dataProvider' => $dataProvider,
'filterModel' => $searchModel,
'summary' => 'Показано {begin}–{end} из {totalCount}',
'columns' => [
['class' => 'yii\grid\SerialColumn'],
'id',
'title',
'author.name', // атрибут связи
'created_at:datetime', // формат
[
'attribute' => 'status',
'value' => fn ($model) => $model->statusLabel,
'filter' => ['0' => 'Черновик', '1' => 'Опубликован'],
'filterInputOptions' => ['class' => 'form-control', 'prompt' => 'Все'],
'contentOptions' => ['class' => 'text-center'],
],
[
'class' => 'yii\grid\ActionColumn',
'template' => '{view} {update} {delete}',
'buttons' => [
'view' => fn ($url, $model, $key) => Html::a('👁', $url),
],
'visibleButtons' => [
'update' => fn ($model) => Yii::$app->user->can('updatePost', ['post' => $model]),
],
'urlCreator' => fn ($action, $model, $key, $index) => Url::to([$action, 'id' => $model->id]),
],
['class' => 'yii\grid\CheckboxColumn'],
],
]);Классы колонок: DataColumn (по умолчанию), SerialColumn, ActionColumn, CheckboxColumn, RadioButtonColumn. Общие свойства любой колонки: header, footer, visible, content, headerOptions, contentOptions, filterOptions, footerOptions.
Выбранные чекбоксами строки на клиенте: $('#grid').yiiGridView('getSelectedRows').
Фильтрация: модель поиска#
class PostSearch extends Post
{
public $createdFrom;
public $createdTo;
public function rules()
{
return [
[['id'], 'integer'],
[['title', 'created_at', 'author.name', 'createdFrom', 'createdTo'], 'safe'],
];
}
public function scenarios() { return \yii\base\Model::scenarios(); } // все атрибуты активны
public function attributes()
{
return array_merge(parent::attributes(), ['author.name']); // атрибут связи для поиска
}
public function search($params)
{
$query = Post::find();
$dataProvider = new ActiveDataProvider(['query' => $query]);
$query->joinWith(['author' => fn ($q) => $q->from(['author' => 'user'])]);
$dataProvider->sort->attributes['author.name'] = [
'asc' => ['author.name' => SORT_ASC],
'desc' => ['author.name' => SORT_DESC],
];
if (!($this->load($params) && $this->validate())) {
return $dataProvider;
}
$query->andFilterWhere(['id' => $this->id])
->andFilterWhere(['like', 'title', $this->title])
->andFilterWhere(['like', 'author.name', $this->getAttribute('author.name')])
->andFilterWhere(['>=', 'created_at', $this->createdFrom])
->andFilterWhere(['<=', 'created_at', $this->createdTo]);
return $dataProvider;
}
}$searchModel = new PostSearch();
$dataProvider = $searchModel->search(Yii::$app->request->queryParams);Альтернатива для сложных выборок — SQL-VIEW + отдельная AR-модель на него: сортировка и фильтры по всем полям работают «из коробки» и быстрее, но доменная логика в такой модели дублируется.
Несколько GridView на странице — разведите параметры:
$userProvider->pagination->pageParam = 'user-page';
$userProvider->sort->sortParam = 'user-sort';ListView и DetailView#
echo \yii\widgets\ListView::widget([
'dataProvider' => $dataProvider,
'itemView' => '_post', // в виде доступны $model, $key, $index, $widget
'viewParams' => ['fullView' => true],
'layout' => "{summary}\n{items}\n{pager}",
]);
echo \yii\widgets\DetailView::widget([
'model' => $model,
'attributes' => [
'title',
'description:html',
['label' => 'Автор', 'value' => $model->author->name],
'created_at:datetime',
],
]);JS и CSS из PHP#
$this->registerJs("$('#b').on('click', fn);", View::POS_READY, 'my-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']);
$this->registerJsVar('yiiOptions', $options); // безопасная передача данных в JSПозиции: POS_HEAD, POS_BEGIN, POS_END, POS_READY (по умолчанию, $(document).ready), POS_LOAD. Передача данных из PHP в JS — только через Json::htmlEncode():
$this->registerJs('var opts = ' . \yii\helpers\Json::htmlEncode($options) . ';', View::POS_HEAD, 'opts');для постоянных файлов используйте asset bundles, а не registerJsFile() — получите управление зависимостями, объединение и минификацию.
Темизация#
'components' => [
'view' => [
'theme' => [
'basePath' => '@app/themes/basic',
'baseUrl' => '@web/themes/basic',
'pathMap' => [
'@app/views' => '@app/themes/basic',
'@app/modules' => '@app/themes/basic/modules', // темы для модулей
'@app/widgets' => '@app/themes/basic/widgets',
],
],
],
],Замена работает по частичному совпадению пути: @app/views/site/about.php → @app/themes/basic/site/about.php. В представлении доступны $this->theme->getUrl(...) и $this->theme->getPath(...). Значением pathMap может быть массив путей — тогда используется первый существующий файл (удобно для наследования тем).
Безопасность
Аутентификация, ACF и RBAC, пароли, криптография, защита от SQL-инъекций, XSS, CSRF.
Два базовых принципа: фильтровать ввод и экранировать вывод.
Аутентификация#
'components' => [
'user' => [
'identityClass' => 'app\models\User',
'enableAutoLogin' => true, // «запомнить меня» через cookie
'enableSession' => true, // false для stateless REST API
'loginUrl' => ['site/login'],
'authTimeout' => 3600,
],
],Класс идентификации реализует yii\web\IdentityInterface:
class User extends ActiveRecord implements \yii\web\IdentityInterface
{
public static function findIdentity($id) { return static::findOne($id); }
public static function findIdentityByAccessToken($token, $type = null)
{
return static::findOne(['access_token' => $token]);
}
public function getId() { return $this->id; }
public function getAuthKey() { return $this->auth_key; }
public function validateAuthKey($authKey) { return $this->getAuthKey() === $authKey; }
public function beforeSave($insert)
{
if (!parent::beforeSave($insert)) { return false; }
if ($this->isNewRecord) {
$this->auth_key = Yii::$app->security->generateRandomString();
}
return true;
}
}Yii::$app->user->identity; // объект identity или null
Yii::$app->user->id;
Yii::$app->user->isGuest;
Yii::$app->user->login($identity, 3600 * 24 * 30); // второй аргумент — срок cookie
Yii::$app->user->logout(); // logout(false) — сохранить данные сессии
Yii::$app->user->loginRequired();События: EVENT_BEFORE_LOGIN (можно отменить через $event->isValid = false), EVENT_AFTER_LOGIN, EVENT_BEFORE_LOGOUT, EVENT_AFTER_LOGOUT.
не путайте app\models\User (identity — логика и хранилище пользователя) и yii\web\User (компонент, управляющий состоянием аутентификации).
Авторизация: ACF#
use yii\filters\AccessControl;
public function behaviors()
{
return [
'access' => [
'class' => AccessControl::class,
'only' => ['login', 'logout', 'signup', 'admin'],
'denyCallback' => function ($rule, $action) {
throw new \yii\web\ForbiddenHttpException('Нет доступа');
},
'rules' => [
['allow' => true, 'actions' => ['login', 'signup'], 'roles' => ['?']], // гости
['allow' => true, 'actions' => ['logout'], 'roles' => ['@']], // авторизованные
['allow' => true, 'actions' => ['admin'], 'roles' => ['admin'], // роль RBAC
'ips' => ['192.168.*'], 'verbs' => ['GET', 'POST'],
'matchCallback' => fn ($rule, $action) => date('N') < 6],
],
],
];
}Правила проверяются сверху вниз до первого совпадения; если ни одно не подошло — доступ запрещён. Гостя по умолчанию отправят на loginUrl, авторизованный получит 403. Свойства правила: allow, actions, controllers, roles, permissions, ips, verbs, matchCallback, denyCallback.
Авторизация: RBAC#
'components' => [
'authManager' => [
'class' => 'yii\rbac\DbManager', // или yii\rbac\PhpManager (файлы в @app/rbac)
'defaultRoles' => ['guest'],
],
],Для DbManager нужны таблицы: yii migrate --migrationPath=@yii/rbac/migrations (auth_item, auth_item_child, auth_assignment, auth_rule).
// консольная команда инициализации
$auth = Yii::$app->authManager;
$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); // роль включает другую роль
$auth->assign($author, 2); // ID пользователя из getId()
$auth->assign($admin, 1);Правило (rule) — дополнительная проверка во время авторизации:
namespace app\rbac;
class AuthorRule extends \yii\rbac\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->ruleName = $rule->name;
$auth->add($updateOwnPost);
$auth->addChild($updateOwnPost, $updatePost);
$auth->addChild($author, $updateOwnPost);Проверка доступа:
if (Yii::$app->user->can('createPost')) { /* ... */ }
if (Yii::$app->user->can('updatePost', ['post' => $post])) { /* ... */ }
Yii::$app->authManager->checkAccess($userId, 'updatePost', ['post' => $post]);Роли по умолчанию (defaultRoles) назначаются всем неявно — обычно вместе с правилом, которое определяет принадлежность пользователя к роли (например, по колонке group).
если права выдаются «пакетом» на весь CRUD, проще завести одно разрешение managePost и проверять его в beforeAction(), а не расписывать правило на каждое действие.
Пароли#
$hash = Yii::$app->security->generatePasswordHash($password); // bcrypt
if (Yii::$app->security->validatePassword($password, $hash)) { /* ok */ }Никогда не md5()/sha1() — они подбираются перебором на современном железе.
Криптография#
$security = Yii::$app->security;
$security->generateRandomString(32); // токены сброса пароля и т.п.
$security->generateRandomKey(32);
$encrypted = $security->encryptByPassword($data, $password);
$data = $security->decryptByPassword($encrypted, $password);
$encrypted = $security->encryptByKey($data, $key);
$data = $security->decryptByKey($encrypted, $key);
$signed = $security->hashData($data, $secretKey); // подпись целостности
$data = $security->validateData($signed, $secretKey); // false, если подделали
$security->compareString($expected, $actual); // сравнение без утечки по времениТиповые угрозы и защита#
| Угроза | Защита в Yii |
|---|---|
| SQL-инъекция | подготовленные запросы: AR, where(['col' => $v]), bindValue(). Имена столбцов — только по белому списку, экранирование [[col]], {{table}} |
| XSS | Html::encode() для текста, HtmlPurifier::process() для HTML от пользователя |
| CSRF | включено по умолчанию: Html::csrfMetaTags() в layout, скрытое поле в ActiveForm; GET не должен менять состояние |
| Массовое присвоение лишних полей | сценарии и scenarios(), safe-атрибуты |
| Подделка cookie | request.cookieValidationKey + валидация кук |
| Утечка внутренностей | YII_DEBUG = false и YII_ENV = 'prod' на продакшене |
| Gii/Debug на проде | не включать вообще; в крайнем случае — allowedIPs |
Доступ к файлам вне web/ | корень веб-сервера — только web/ |
| Host-header атака | настройка веб-сервера или фильтр yii\filters\HostControl |
| Незащищённый транспорт | HTTPS/TLS, Cookie::secure, sameSite |
// глобальный фильтр допустимых хостов
'as hostControl' => [
'class' => 'yii\filters\HostControl',
'allowedHosts' => ['example.com', '*.example.com'],
'fallbackHostInfo' => 'https://example.com',
],// белый список вместо подстановки имени столбца из запроса
public function actionList($orderBy = null)
{
if (!in_array($orderBy, ['name', 'status'], true)) {
throw new \yii\web\BadRequestHttpException('Недопустимая сортировка.');
}
}За reverse proxy обязательно настройте request.trustedHosts, иначе IP и схема соединения будут подделываемыми.
Кеширование
Кеш данных, зависимости, кеш запросов, фрагменты, страницы, HTTP-кеш.
Четыре уровня: данные → запросы → фрагменты → страница, плюс HTTP-кеш на стороне клиента.
Кеш данных#
$cache = Yii::$app->cache;
// классический паттерн
$data = $cache->get($key);
if ($data === false) {
$data = $this->calculate();
$cache->set($key, $data, 3600);
}
// то же самое одной строкой (с 2.0.11)
$data = $cache->getOrSet($key, fn () => $this->calculate(), 3600, $dependency);| Метод | Действие |
|---|---|
get($key) | значение или false |
set($key, $value, $duration, $dependency) | записать |
add() | записать, только если ключа ещё нет |
getOrSet($key, $callable, $duration, $dependency) | получить или вычислить и запомнить |
multiGet(), multiSet(), multiAdd() | пакетные операции |
exists(), delete(), flush() | проверка, удаление, полная очистка |
Cache реализует ArrayAccess: $cache['key'] = $value.
не кешируйте само значение false — get() использует его как признак промаха. Оборачивайте в массив.
Хранилища#
| Класс | Особенности |
|---|---|
yii\caching\FileCache | файлы; хорош для больших объёмов, работает везде |
yii\caching\ApcCache | APCu, самый быстрый на одном сервере |
yii\caching\MemCache | memcache/memcached, для нескольких серверов |
yii\redis\Cache | Redis (расширение yii2-redis) |
yii\caching\DbCache | таблица в БД |
yii\caching\ArrayCache | только в пределах запроса |
yii\caching\DummyCache | заглушка: код с кешем работает без кеша |
'components' => [
'cache' => [
'class' => 'yii\caching\MemCache',
'keyPrefix' => 'myapp', // если хранилище общее для нескольких приложений
'defaultDuration' => 3600,
'servers' => [
['host' => 'server1', 'port' => 11211, 'weight' => 100],
['host' => 'server2', 'port' => 11211, 'weight' => 50],
],
],
],Ключи и зависимости#
Ключ — строка или любое значение (сериализуется). Составляйте его из всех значимых частей: [__CLASS__, $db->dsn, $tableName].
$dependency = new \yii\caching\DbDependency(['sql' => 'SELECT MAX(updated_at) FROM post']);
$cache->set($key, $data, 30, $dependency);| Зависимость | Инвалидируется, когда |
|---|---|
DbDependency | меняется результат SQL-запроса |
FileDependency | меняется время модификации файла |
ExpressionDependency | меняется результат PHP-выражения |
DbQueryDependency | меняется результат запроса Query |
CallbackDependency | меняется результат коллбэка |
TagDependency | вызван TagDependency::invalidate($cache, 'tag') |
ChainedDependency | изменилась любая зависимость в цепочке |
$cache->set($key, $data, 0, new \yii\caching\TagDependency(['tags' => ['post', "post-$id"]]));
\yii\caching\TagDependency::invalidate(Yii::$app->cache, "post-$id");Кеш запросов к БД#
$result = $db->cache(function ($db) {
return Customer::find()->where(['id' => 1])->one();
}, $duration, $dependency);
$db->cache(function ($db) {
$db->noCache(function ($db) {
// этот запрос не кешируется
});
});
// точечно для одной команды
$db->createCommand($sql)->cache(60)->queryOne();
$db->createCommand($sql)->noCache()->queryOne();Настройки соединения: enableQueryCache, queryCacheDuration, queryCache (ID компонента). Не работает с ресурсами (BLOB-хендлеры) и превышающими лимит хранилища результатами.
Отдельно стоит включить кеш схемы — он экономит запросы вида SHOW COLUMNS:
'db' => [
'enableSchemaCache' => true,
'schemaCacheDuration' => 3600,
'schemaCache' => 'cache',
],Кеш фрагментов#
if ($this->beginCache($id, [
'duration' => 3600,
'dependency' => ['class' => 'yii\caching\DbDependency', 'sql' => 'SELECT MAX(updated_at) FROM post'],
'variations' => [Yii::$app->language],
'enabled' => Yii::$app->request->isGet,
])) {
// ... тяжёлая генерация содержимого ...
$this->endCache();
}Фрагменты можно вкладывать друг в друга (у внутреннего может быть свой срок и свои зависимости). Динамические вставки внутри кешированного фрагмента:
echo $this->renderDynamic('return Yii::$app->user->identity->name;');Кеш страницы#
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',
],
],
];
}Это фильтр действия (в отличие от фрагментов — виджета). Поддерживает те же duration, dependency, variations, enabled.
HTTP-кеш#
public function behaviors()
{
return [
[
'class' => 'yii\filters\HttpCache',
'only' => ['index', 'view'],
'lastModified' => fn ($action, $params) => Post::find()->max('updated_at'),
'etagSeed' => fn ($action, $params) => serialize([$this->findModel()->title]),
'cacheControlHeader' => 'public, max-age=300',
'sessionCacheLimiter' => '',
],
];
}Работает только для GET и HEAD: отдаёт 304 Not Modified, если у клиента свежая версия. Заголовки: Last-Modified / If-Modified-Since, ETag / If-None-Match, Cache-Control.
Очистка#
yii cache # список кеширующих компонентов
yii cache/flush cache1 cache2
yii cache/flush-all
yii cache/flush-schema db # сброс кеша схемы БДу консольного приложения свой конфиг — проверьте, что кеширующие компоненты в console.php и web.php совпадают, иначе yii cache/flush очистит «не тот» кеш.
REST API
ActiveController, маршруты, ресурсы и поля, форматы ответа, аутентификация, лимиты, версии, ошибки.
Минимальный API за три шага#
// 1. Контроллер
namespace app\controllers;
class UserController extends \yii\rest\ActiveController
{
public $modelClass = 'app\models\User';
}// 2. Маршруты
'urlManager' => [
'enablePrettyUrl' => true,
'enableStrictParsing' => true,
'showScriptName' => false,
'rules' => [
['class' => 'yii\rest\UrlRule', 'controller' => 'user'],
],
],
// 3. Приём JSON
'request' => [
'parsers' => ['application/json' => 'yii\web\JsonParser'],
],Готовые точки входа:
| Метод и путь | Действие |
|---|---|
GET /users | постраничный список |
HEAD /users | метаданные списка |
POST /users | создание |
GET /users/123 | одна запись |
PATCH|PUT /users/123 | обновление |
DELETE /users/123 | удаление |
OPTIONS /users, OPTIONS /users/123 | поддерживаемые методы |
yii\rest\UrlRule сам приводит ID контроллера к множественному числу (Inflector::pluralize()), отключается через 'pluralize' => false.
[
'class' => 'yii\rest\UrlRule',
'controller' => ['u' => 'user'], // явное имя в URL
'except' => ['delete'],
'extraPatterns' => ['GET search' => 'search'],
]Ресурсы: поля и ссылки#
class User extends ActiveRecord implements \yii\web\Linkable
{
public function fields()
{
return [
'id',
'email' => 'email_address', // переименование
'name' => fn () => $this->first_name . ' ' . $this->last_name,
];
}
public function extraFields()
{
return ['profile', 'orders']; // доступны по ?expand=profile
}
public function getLinks() // HATEOAS
{
return [\yii\web\Link::REL_SELF => \yii\helpers\Url::to(['user/view', 'id' => $this->id], true)];
}
}GET /users?fields=id,email
GET /users?expand=profile
GET /users?fields=id,email&expand=profile
GET /users?sort=-email
GET /users?filter[id][in][]=2&filter[title][like]=cheeseпо умолчанию в ответ попадают все атрибуты модели. Обязательно уберите password_hash, auth_key, password_reset_token — либо через unset() в fields(), либо явным перечислением полей.
Коллекции лучше возвращать провайдером данных — сериализатор добавит заголовки X-Pagination-Total-Count, X-Pagination-Page-Count, X-Pagination-Current-Page, X-Pagination-Per-Page и Link.
public function actionIndex()
{
return new ActiveDataProvider(['query' => Post::find()]);
}Контроллеры#
yii\rest\Controller даёт согласование содержимого, проверку HTTP-методов, аутентификацию и ограничение частоты запросов. yii\rest\ActiveController добавляет готовые действия index, view, create, update, delete, options и проверку доступа.
public function actions()
{
$actions = parent::actions();
unset($actions['delete'], $actions['create']);
$actions['index']['prepareDataProvider'] = [$this, 'prepareDataProvider'];
return $actions;
}
public function checkAccess($action, $model = null, $params = [])
{
if (in_array($action, ['update', 'delete'], true) && $model->author_id !== Yii::$app->user->id) {
throw new \yii\web\ForbiddenHttpException('Можно менять только свои записи.');
}
}Фильтры выполняются в порядке: contentNegotiator → verbFilter → authenticator → rateLimiter. Настраиваются переопределением behaviors().
Формат ответа#
public function behaviors()
{
$behaviors = parent::behaviors();
$behaviors['contentNegotiator']['formats']['text/html'] = \yii\web\Response::FORMAT_HTML;
return $behaviors;
}// «конверт» для коллекции: items + _links + _meta в теле ответа
class UserController extends ActiveController
{
public $modelClass = 'app\models\User';
public $serializer = [
'class' => 'yii\rest\Serializer',
'collectionEnvelope' => 'items',
];
}'response' => [
'formatters' => [
\yii\web\Response::FORMAT_JSON => [
'class' => 'yii\web\JsonResponseFormatter',
'prettyPrint' => YII_DEBUG,
'encodeOptions' => JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
],
],
],Аутентификация#
'user' => ['enableSession' => false]— API не хранит состояние.- Настроить
authenticatorв контроллере. - Реализовать
findIdentityByAccessToken()в identity-классе.
use yii\filters\auth\{CompositeAuth, HttpBasicAuth, HttpBearerAuth, QueryParamAuth};
public function behaviors()
{
$behaviors = parent::behaviors();
$behaviors['authenticator'] = [
'class' => CompositeAuth::class,
'authMethods' => [
HttpBasicAuth::class,
HttpBearerAuth::class,
QueryParamAuth::class,
],
'except' => ['options'],
];
return $behaviors;
}Неудачная аутентификация → 401 (+ WWW-Authenticate). Авторизация — через checkAccess() и/или RBAC.
токен доступа в URL попадает в логи веб-сервера. Используйте заголовок Authorization, и только HTTPS — иначе токен перехватывается.
Ограничение частоты запросов#
Identity-класс реализует yii\filters\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();
}Заголовки ответа: X-Rate-Limit-Limit, X-Rate-Limit-Remaining, X-Rate-Limit-Reset (отключаются $behaviors['rateLimiter']['enableRateLimitHeaders'] = false). Превышение → 429 TooManyRequestsHttpException.
Версионирование#
Мажорные версии — отдельные модули (v1, v2), минорные — через заголовок Accept.
'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']],
]],Accept: application/json; version=v1 → Yii::$app->response->acceptParams['version'].
Фильтрация коллекций#
$filter = new \yii\data\ActiveDataFilter([
'searchModel' => (new \yii\base\DynamicModel(['id', 'title']))
->addRule(['id'], 'integer')
->addRule(['title'], 'string', ['min' => 2, 'max' => 200]),
'attributeMap' => ['authorName' => '{{author}}.[[name]]'],
]);
$query = Post::find();
if ($filter->load(Yii::$app->request->get())) {
$condition = $filter->build();
if ($condition === false) { return $filter; } // вернёт ошибки валидации фильтра
$query->andWhere($condition);
}
return new ActiveDataProvider(['query' => $query]);Ключевые слова: and, or, not, lt, gt, lte, gte, eq, neq, in, nin, like (расширяется через filterControls). Обязателен searchModel — он и определяет, по чему разрешено фильтровать.
Коды ответов и ошибки#
| Код | Когда |
|---|---|
| 200 | успех |
| 201 | создано (POST), в Location — URL ресурса |
| 204 | успех без содержимого (DELETE) |
| 304 | не изменялось |
| 400 | некорректный запрос |
| 401 | аутентификация не пройдена |
| 403 | доступ запрещён |
| 404 | ресурс не найден |
| 405 | метод не поддерживается (см. Allow) |
| 415 | неподдерживаемый тип/версия |
| 422 | ошибка валидации данных |
| 429 | слишком много запросов |
| 500 | ошибка сервера |
Тело ошибки по умолчанию: {"name": ..., "message": ..., "code": ..., "status": 404}. Изменить формат можно в response.on beforeSend:
'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;
}
},
],API лучше выносить в отдельное приложение или модуль — так проще не смешивать stateless-логику с сессиями веб-части.
Консоль, интернационализация, почта
Консольные команды, переводы и множественные формы, отправка писем.
Консольные приложения#
yii <маршрут> [--опция1=значение1 ... аргумент1 аргумент2]
yii # список всех команд
yii help migrate # справка по команде
yii <маршрут> --appconfig=path/to/config.php # другой конфигВстроенные команды:
| Команда | Назначение |
|---|---|
yii serve | встроенный веб-сервер PHP (--docroot=./web --port=8080) |
yii migrate/* | миграции БД |
yii cache/* | очистка кешей |
yii asset/* | объединение и минификация ресурсов |
yii message/* | извлечение строк для перевода |
yii fixture/* | загрузка и выгрузка фикстур |
yii help | справка |
yii gii/* | генераторы Gii (с расширением) |
Своя команда#
namespace app\commands;
use yii\console\Controller;
use yii\console\ExitCode;
use yii\helpers\Console;
/**
* Управление рассылкой писем.
*/
class MailController extends Controller
{
public $message;
public $limit = 100;
public function options($actionID) // какие свойства доступны как --опции
{
return ['message', 'limit'];
}
public function optionAliases() // короткие псевдонимы: -m
{
return ['m' => 'message'];
}
/**
* Отправляет письма пачкой.
* @param string $category категория рассылки
*/
public function actionSend($category, $order = 'name')
{
$this->stdout("Отправка...\n", Console::BOLD);
$name = $this->ansiFormat($category, Console::FG_YELLOW);
if (!$this->confirm("Отправить рассылку $name?")) {
return ExitCode::OK;
}
if ($somethingWrong) {
$this->stderr("Ошибка\n", Console::FG_RED);
return ExitCode::UNSPECIFIED_ERROR; // 1
}
return ExitCode::OK; // 0
}
public function actionBatch(array $ids) { /* yii mail/batch 1,2,3 */ }
}yii mail/send news -m=Привет --limit=50- Класс команды — наследник
yii\console\Controller, действия —actionXxx. - Аргументы действия заполняются позиционно, опции — через
--name=value. - Тип
arrayв параметре → значение разбивается по запятым. - Код возврата:
0— успех, иначе ошибка (ExitCode::OK,ExitCode::UNSPECIFIED_ERROR, …). - Команды из модулей: задайте
controllerNamespaceдля консоли вinit()модуля, вызов —yii <module>/<command>/<action>. - Есть автодополнение для Bash и ZSH (скрипты в
contrib/completionрепозитория Yii).
Полезные методы контроллера: stdout(), stderr(), ansiFormat(), prompt(), confirm(), select(). Прогресс-бар и перенос длинных строк — в хелпере Console: startProgress(), updateProgress(), endProgress(), wrapText().
Интернационализация#
'language' => 'ru-RU', // язык вывода
'sourceLanguage' => 'en-US', // язык строк в кодеYii::$app->language = 'ru-RU'; // можно менять в рантайме (до вывода)
echo Yii::t('app', 'Hello, {username}!', ['username' => $name]);'components' => [
'i18n' => [
'translations' => [
'app*' => [
'class' => 'yii\i18n\PhpMessageSource', // либо GettextMessageSource / DbMessageSource
'basePath' => '@app/messages',
'sourceLanguage' => 'en-US',
'fileMap' => ['app' => 'app.php', 'app/error' => 'error.php'],
'on missingTranslation' => ['app\components\TranslationEventHandler', 'handleMissingTranslation'],
],
'yii' => [ // переопределение сообщений фреймворка
'class' => 'yii\i18n\PhpMessageSource',
'basePath' => '@app/messages',
],
'*' => ['class' => 'yii\i18n\PhpMessageSource'], // fallback для всех категорий
],
],
],Файлы переводов: @app/messages/ru-RU/app.php (fallback — @app/messages/ru/app.php), массив 'исходная строка' => 'перевод'.
Форматирование в сообщениях (нужен intl)#
Yii::t('app', 'Баланс: {0, number, currency}', $sum);
Yii::t('app', 'Сегодня {0, date, long}', time());
Yii::t('app', 'Время {0, time, short}', time());
Yii::t('app', '{n, spellout}', ['n' => 42]); // сорок два
Yii::t('app', 'Вы {n, ordinal} посетитель', ['n' => 42]);
Yii::t('app', 'Прошло {n, duration}', ['n' => 47]);Множественные формы — то, за что стоит любить intl:
echo Yii::t('app',
'На диване {n, plural, =0{нет кошек} =1{лежит одна кошка} one{лежит # кошка} few{лежит # кошки} many{лежит # кошек} other{лежит # кошки}}!',
['n' => $count]
);=0/=1 — точные значения; one (21, 31…), few (2–4, 22–24…), many (0, 5–20…), other — остальное; # подставляет число. Если один указатель используется и как plural, и как число — второй раз он должен быть {count, number}, иначе U_ARGUMENT_TYPE_MISMATCH.
Выбор по ключу:
Yii::t('app', '{name} — {gender, select, женщина{ей} мужчина{ему} other{ему}} нравится Yii!',
['name' => 'Василий', 'gender' => 'мужчина']);Извлечение строк и переводы представлений#
yii message/config-template config/i18n.php # создать конфиг
yii message config/i18n.php # извлечь строки в файлы переводовЛокализованные представления: views/site/ru-RU/index.php подхватится автоматически вместо views/site/index.php. То же работает для модулей и виджетов — они обычно регистрируют свои переводы в init() и дают статический метод Module::t().
без расширения intl работают только базовые возможности; форматирование зависит от версии ICU, поэтому на всех окружениях держите одинаковые версии intl/ICU.
Отправка почты#
'components' => [
'mailer' => [
'class' => 'yii\symfonymailer\Mailer', // в старых проектах: yii\swiftmailer\Mailer
'viewPath' => '@app/mail',
'useFileTransport' => YII_ENV_DEV, // письма в @runtime/mail вместо отправки
'transport' => [
'scheme' => 'smtps',
'host' => 'smtp.example.com',
'username' => 'user',
'password' => 'secret',
'port' => 465,
],
],
],Yii::$app->mailer->compose()
->setFrom('from@example.com')
->setTo('to@example.com')
->setSubject('Тема')
->setTextBody('Текст')
->setHtmlBody('<b>HTML</b>')
->send();Через представления (файлы в @app/mail):
Yii::$app->mailer->compose('greetings', ['user' => $user]) // одно представление
Yii::$app->mailer->compose(['html' => 'contact-html', 'text' => 'contact-text'], $params)
->setFrom(...)->setTo(...)->setSubject(...)->send();Layout писем задаётся через htmlLayout / textLayout компонента mailer.
$message->attach('/path/to/file.pdf');
$message->attachContent($csv, ['fileName' => 'report.csv', 'contentType' => 'text/csv']);
// картинка внутри письма (в представлении)
<img src="<?= $message->embed($imageFileName) ?>">
Yii::$app->mailer->sendMultiple($messages); // пачкой, одним соединениемна разработке включайте useFileTransport — письма складываются в @runtime/mail и их можно открыть текстовым редактором, ничего никуда не уходит.
Хелперы
ArrayHelper, Html, Url, Json, StringHelper, FileHelper, Inflector и другие.
Статические классы в yii\helpers. У каждого есть базовый класс (BaseArrayHelper) — используйте всегда конкретный (ArrayHelper), а для подмены реализации наследуйте базовый и подставьте через Yii::$classMap.
Полный список: ArrayHelper, Html, HtmlPurifier, Url, Json, StringHelper, FileHelper, Inflector, Console, FormatConverter, Markdown, VarDumper, IpHelper, Imagine (расширение yii2-imagine).
ArrayHelper#
use yii\helpers\ArrayHelper;
// Чтение вложенных значений — без isset-лапши
ArrayHelper::getValue($array, 'foo.bar.name', 'по умолчанию');
ArrayHelper::getValue($user, fn ($u, $default) => $u->firstName . ' ' . $u->lastName);
ArrayHelper::setValue($array, 'key.in', ['arr' => 'val']);
$type = ArrayHelper::remove($array, 'type'); // вернуть и удалить
ArrayHelper::keyExists('username', $data, false); // без учёта регистра
// Столбцы, индексация, пары
ArrayHelper::getColumn($rows, 'id');
ArrayHelper::index($rows, 'id'); // ['123' => [...], ...]
ArrayHelper::index($rows, null, 'class'); // группировка
ArrayHelper::map($rows, 'id', 'name'); // для dropDownList
ArrayHelper::map($rows, 'id', 'name', 'class'); // с группировкой
// Сортировка и проверки
ArrayHelper::multisort($data, ['age', 'name'], [SORT_ASC, SORT_DESC]);
ArrayHelper::isIndexed($array); ArrayHelper::isAssociative($array);
ArrayHelper::isIn('a', $traversable); ArrayHelper::isSubset($a, $b);
// Прочее
ArrayHelper::merge($a, $b, $c); // рекурсивное слияние
ArrayHelper::htmlEncode($data); ArrayHelper::htmlDecode($data);
ArrayHelper::flatten($nested, '.'); // ['a.b.c' => 1]
ArrayHelper::filter($array, ['A', '!A.B']); // выборка по путям
ArrayHelper::toArray($posts, [
Post::class => ['id', 'title', 'createTime' => 'created_at',
'length' => fn ($post) => strlen($post->content)],
]);ArrayHelper::merge() перезаписывает строковые ключи последним значением (в отличие от array_merge_recursive), а элементы с числовыми ключами — добавляет. Именно поэтому им удобно сливать конфиги.
Html#
use yii\helpers\Html;
Html::encode($text); Html::decode($html);
Html::tag('p', Html::encode($name), ['class' => 'username']);
Html::beginTag('div', $options) . Html::endTag('div');
// CSS-классы и стили в массиве опций
Html::addCssClass($options, ['btn-success', 'btn-lg']); // без дублей
Html::removeCssClass($options, 'btn-default');
Html::addCssStyle($options, ['height' => '200px']);
Html::removeCssStyle($options, ['width', 'height']);
Html::cssStyleFromArray($styles); Html::cssStyleToArray($style);
// Ссылки, письма, картинки, списки
Html::a('Профиль', ['user/view', 'id' => $id], ['class' => 'link']);
Html::mailto('Написать', 'admin@example.com');
Html::img('@web/images/logo.png', ['alt' => 'Логотип']);
Html::ul($items, ['item' => fn ($item, $i) => Html::tag('li', $item)]);
Html::ol($items);
// Стили и скрипты
Html::style('.danger { color: #f00 }', ['media' => 'print']);
Html::script('alert(1);');
Html::cssFile('@web/css/print.css', ['media' => 'print']);
Html::jsFile('@web/js/main.js');
Html::csrfMetaTags();
// Формы
Html::beginForm(['order/update', 'id' => $id], 'post', ['enctype' => 'multipart/form-data']);
Html::endForm();
Html::submitButton('Отправить'); Html::button('Кнопка'); Html::resetButton('Сброс');
// Поля ввода: обычные и «active» (значение берётся из модели)
Html::textInput('username', $value, $options);
Html::activeTextInput($model, 'username', $options);
Html::activeHiddenInput($model, 'version');
Html::activePasswordInput($model, 'password');
Html::activeFileInput($model, 'file');
Html::activeTextarea($model, 'about');
Html::activeCheckbox($model, 'agree'); Html::activeRadio($model, 'type');
Html::activeDropDownList($model, 'id', ArrayHelper::map($rows, 'id', 'name'));
Html::activeListBox($model, 'ids', $items);
Html::activeCheckboxList($model, 'roles', $items);
Html::activeRadioList($model, 'role', $items);
// Метки, ошибки, служебные имена
Html::activeLabel($model, 'username');
Html::errorSummary($models, ['class' => 'errors']);
Html::error($model, 'title');
Html::getInputName($model, 'title'); // Post[title]
Html::getInputId($model, 'title'); // post-title
Html::getAttributeValue($model, '[0]authors[0]');
Html::getAttributeName('dates[0]'); // datesОбработка массива опций: null — атрибут не выводится, true/false — логический атрибут, значения экранируются, 'data' => ['id' => 1] → data-id="1", вложенный массив в data-атрибуте → JSON.
use yii\helpers\HtmlPurifier;
echo HtmlPurifier::process($userHtml); // очистка HTML от XSS (медленно — кешируйте)Url#
use yii\helpers\Url;
Url::home(); Url::home(true); Url::home('https');
Url::base(); Url::base(true);
Url::toRoute('site/index');
Url::toRoute(['post/view', 'id' => 42, '#' => 'comments']);
Url::to(['post/index', 'page' => 2]);
Url::to('@web/images/logo.gif', true);
Url::to(); // текущий URL
Url::current(['page' => 2]); // текущий маршрут + изменённые GET-параметры
Url::current(['src' => null]); // удалить параметр
Url::canonical();
Url::remember(); Url::previous();
Url::remember(['product/view', 'id' => 42], 'product'); Url::previous('product');
Url::isRelative('test/it');Json#
use yii\helpers\Json;
Json::encode($data); // JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
Json::encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
Json::htmlEncode($data); // безопасно для вставки в HTML/JS
Json::decode($json); // в массив
Json::$prettyPrint = YII_DEBUG; // глобально
Json::encode(['fn' => new \yii\web\JsExpression('function () { return 1; }')]); // JS-выражение не экранируетсяStringHelper#
use yii\helpers\StringHelper;
StringHelper::byteLength($str); StringHelper::byteSubstr($str, 0, 10);
StringHelper::truncate($str, 30, '…');
StringHelper::truncateWords($str, 10);
StringHelper::startsWith($str, 'Yii'); StringHelper::endsWith($str, '.php');
StringHelper::basename('app\models\Post'); // Post — удобно для имени класса
StringHelper::dirname($path);
StringHelper::explode('a, b, c', ',', true, true);
StringHelper::countWords($str);
StringHelper::normalizeNumber('4.2');
StringHelper::base64UrlEncode($str); StringHelper::base64UrlDecode($str);
StringHelper::matchWildcard('*.example.com', $host);
StringHelper::mb_ucfirst($str); StringHelper::mb_ucwords($str);FileHelper#
use yii\helpers\FileHelper;
FileHelper::createDirectory('@runtime/export', 0775, true);
FileHelper::copyDirectory($src, $dst, ['only' => ['*.php'], 'except' => ['.svn/']]);
FileHelper::removeDirectory($dir);
FileHelper::findFiles('@app/views', ['only' => ['*.php'], 'recursive' => true]);
FileHelper::findDirectories($dir);
FileHelper::getMimeType('/path/file.pdf');
FileHelper::getExtensionsByMimeType('image/jpeg');
FileHelper::normalizePath($path);
FileHelper::localize('@app/views/site/index.php'); // версия под текущий языкInflector#
use yii\helpers\Inflector;
Inflector::pluralize('box'); // boxes
Inflector::singularize('boxes'); // box
Inflector::camelize('post_comment'); // PostComment
Inflector::camel2words('PostComment');// Post Comment
Inflector::camel2id('PostComment'); // post-comment
Inflector::id2camel('post-comment'); // PostComment
Inflector::underscore('PostComment'); // post_comment
Inflector::slug('Привет, мир!', '-'); // privet-mir (нужен intl для транслитерации)
Inflector::titleize($str);
Inflector::ordinalize(21); // 21st
Inflector::sentence(['a', 'b', 'c']); // a, b and cПрочее#
use yii\helpers\{VarDumper, Markdown, FormatConverter, IpHelper, Console};
VarDumper::dump($var, 10, true); // безопасный var_dump с подсветкой
VarDumper::export($var); // валидный PHP-код
echo Markdown::process($text, 'gfm'); // Markdown → HTML
FormatConverter::convertDateIcuToPhp('yyyy-MM-dd');
IpHelper::inRange('192.168.1.5', '192.168.0.0/16');
Console::stdout(Console::ansiFormat('OK', [Console::FG_GREEN]));Тестирование, производительность, эксплуатация
Codeception и фикстуры, оптимизация, деплой, Docker, микро-фреймворк, шаблонизаторы.
Тестирование#
Yii официально интегрирован с Codeception; в шаблонах приложений тесты уже настроены.
| Тип | Что проверяет | Скорость |
|---|---|---|
| Модульные (unit) | отдельный класс/метод, PHPUnit | быстро |
| Функциональные | сценарий пользователя, приложение запускается из кода | средне |
| Приёмочные (acceptance) | сценарий через реальный браузер/PhpBrowser по HTTP | медленно |
composer require --dev codeception/codeception codeception/module-yii2 codeception/module-asserts
vendor/bin/codecept run
vendor/bin/codecept run unit
vendor/bin/codecept run functionalДля приёмочных тестов создают отдельный виртуальный хост, работающий через index-test.php и отдельный конфиг тестовой БД.
Фикстуры#
namespace app\tests\fixtures;
class UserFixture extends \yii\test\ActiveFixture
{
public $modelClass = 'app\models\User'; // либо $tableName
}
class UserProfileFixture extends \yii\test\ActiveFixture
{
public $modelClass = 'app\models\UserProfile';
public $depends = [UserFixture::class]; // загрузится ПОСЛЕ UserFixture
}Данные — @app/tests/fixtures/data/user.php:
return [
'user1' => ['username' => 'lmayert', 'email' => 'a@example.com', 'auth_key' => '...'],
'user2' => ['username' => 'napoleon69', 'email' => 'b@example.com', 'auth_key' => '...'],
];public function fixtures() // или globalFixtures() для общих
{
return ['profiles' => UserProfileFixture::class];
}
$row = $this->profiles['user1']; // строка данных
$model = $this->profiles('user1'); // AR-модельyii fixture/load User
yii fixture "User, UserProfile"
yii fixture/load "*"
yii fixture "*, -DoNotLoadThisOne"
yii fixture/unload "*"
yii fixture User --namespace='app\tests\fixtures'Генерация тестовых данных — расширение yii2-faker. Автозаполняемые столбцы в данных указывать не нужно.
Оптимизация производительности#
| Что сделать | Как |
|---|---|
| Свежий PHP + OPcache | opcache.enable=1, opcache.validate_timestamps=0 на проде |
| Отключить отладку | YII_DEBUG = false, YII_ENV = 'prod', убрать debug/gii |
| Кеш схемы БД | enableSchemaCache => true в компоненте db |
| Кеширование данных и страниц | см. раздел «Кеширование» |
| Ресурсы | объединение и минификация (yii asset), appendTimestamp |
| Сессии | DbSession, CacheSession или yii\redis\Session вместо файлов |
Индексы БД и LIMIT | индексы по полям фильтрации, VIEW для сложных запросов |
| Массивы вместо AR | Post::find()->asArray()->all() при выборке больших списков |
| Автозагрузчик Composer | composer dumpautoload -o --no-dev |
| Тяжёлые операции | в очередь (yii2-queue, RabbitMQ) или в cron |
// узкие места ищем профайлером
Yii::beginProfile('heavy-block');
// ...
Yii::endProfile('heavy-block');Инструменты: панель yii2-debug (запросы, логи, таймлайн), Xdebug Profiler, XHProf.
asArray() экономит память и время, но отключает приведение типов, связи, геттеры и события AR — применяйте там, где нужны только данные для вывода.
Деплой и окружения#
// web/index.php на продакшене
defined('YII_DEBUG') or define('YII_DEBUG', false);
defined('YII_ENV') or define('YII_ENV', 'prod');Чек-лист перед продакшеном:
- корень веб-сервера —
web/, доступ кruntime/,config/,vendor/закрыт; YII_DEBUG = false,YII_ENV = 'prod',gii/debugвыключены;cookieValidationKeyзадан и не лежит в репозитории (переменные окружения);runtime/иweb/assets/доступны на запись процессу веб-сервера;- HTTPS,
Cookie::secure,sameSite; composer install --no-dev -o;- миграции применены:
yii migrate --interactive=0; - кеши сброшены:
yii cache/flush-all; - логи и мониторинг настроены (
FileTarget/DbTarget+ уровеньerror).
Виртуальный хостинг#
Один webroot — берите шаблон basic: переименуйте web в public_html/www, поправьте пути в index.php, положите .htaccess с RewriteRule . index.php. Для advanced переносят входные скрипты в один webroot (www и www/admin) и разделяют куки/сессии backend и frontend:
'components' => [
'request' => ['csrfParam' => '_backendCSRF', 'csrfCookie' => ['path' => '/admin']],
'user' => ['identityCookie' => ['name' => '_backendIdentity', 'path' => '/admin']],
'session' => ['name' => 'BACKENDSESSID', 'cookieParams' => ['path' => '/admin']],
],Docker#
docker-compose up -d # поднять стек
docker-compose ps # список сервисов
docker-compose logs -f # логи
docker-compose exec php bash # шелл в работающем контейнере
docker-compose run --rm php composer install
docker-compose down -v # остановить и удалить (осторожно с данными)Базовые образы — yiisoft/yii2-docker; в yii2-app-basic поддержка Docker уже есть.
Yii как микро-фреймворк#
Шаблоны приложений необязательны — достаточно composer.json, входного скрипта и конфига:
{
"require": { "yiisoft/yii2": "~2.0.0" },
"repositories": [{ "type": "composer", "url": "https://asset-packagist.org" }]
}// config.php
return [
'id' => 'micro-app',
'basePath' => __DIR__,
'controllerNamespace' => 'micro\controllers',
'aliases' => ['@micro' => __DIR__],
'components' => [
'db' => ['class' => 'yii\db\Connection', 'dsn' => 'sqlite:@micro/database.sqlite'],
],
];// web/index.php
require __DIR__ . '/../vendor/autoload.php';
require __DIR__ . '/../vendor/yiisoft/yii2/Yii.php';
(new yii\web\Application(require __DIR__ . '/../config.php'))->run();vendor/bin/yii serve --docroot=./web
vendor/bin/yii migrate/create --appconfig=config.php create_post_table --fields="title:string,body:text"Итоговая структура: composer.json, config.php, web/index.php, controllers/, models/. Идеально для JSON API — контроллер на yii\rest\ActiveController и всё.
Сторонний код и шаблонизаторы#
// библиотека без автозагрузчика, но с PSR-4
'aliases' => ['@xyz' => '@vendor/foo/bar'],
// совсем legacy — карта классов
Yii::$classMap['Class1'] = 'path/to/Class1.php';Yii можно подключить внутрь чужой системы (WordPress, другой фреймворк) — создать объект приложения, но не вызывать run():
require __DIR__ . '/../vendor/yiisoft/yii2/Yii.php';
new yii\web\Application(require __DIR__ . '/../config/yii/web.php'); // без run()Шаблонизаторы Twig/Smarty подключаются как рендереры представлений:
'view' => [
'renderers' => [
'twig' => [
'class' => 'yii\twig\ViewRenderer',
'cachePath' => '@runtime/Twig/cache',
'globals' => ['html' => '\yii\helpers\Html'],
],
],
],Расширение выбирается по суффиксу файла представления (index.twig).
Быстрая шпаргалка
Yii::$app, частые сниппеты, консольные команды, структура проекта — всё на одной странице.
Yii::$app — что где лежит#
Yii::$app->request // GET/POST, заголовки, куки, IP
Yii::$app->response // код, заголовки, формат, файлы, редиректы
Yii::$app->db // соединение с БД
Yii::$app->user // identity, id, isGuest, login(), logout(), can()
Yii::$app->session // сессия, flash-сообщения
Yii::$app->cache // кеш
Yii::$app->urlManager // разбор/создание URL
Yii::$app->view // рендеринг, registerJs/registerCss
Yii::$app->assetManager // публикация ресурсов
Yii::$app->formatter // даты, числа, размеры
Yii::$app->i18n // переводы
Yii::$app->mailer // почта
Yii::$app->security // хеши, шифрование, случайные строки
Yii::$app->authManager // RBAC
Yii::$app->errorHandler // текущее исключение
Yii::$app->log // цели логов
Yii::$app->params['key'] // свои параметры
Yii::$app->language // текущий язык
Yii::$app->controller // текущий контроллер (и ->action, ->module)
Yii::$app->getModule('admin')
Yii::$container // DI-контейнерСтатические методы Yii#
Yii::getAlias('@app/runtime'); Yii::setAlias('@img', '@webroot/img');
Yii::createObject($config); Yii::configure($object, $config);
Yii::t('app', 'Сообщение', $params);
Yii::debug($msg, __METHOD__); Yii::info(); Yii::warning(); Yii::error();
Yii::beginProfile('x'); Yii::endProfile('x');
Yii::$classMap['Foo'] = '@app/Foo.php';
Yii::getVersion();Частые сниппеты контроллера#
// данные запроса
$id = Yii::$app->request->get('id');
$data = Yii::$app->request->post();
$isAjax = Yii::$app->request->isAjax;
// ответы
return $this->render('view', ['model' => $model]);
return $this->renderAjax('_form', ['model' => $model]);
return $this->redirect(['view', 'id' => $model->id]);
return $this->goBack(); return $this->goHome(); return $this->refresh();
return $this->asJson(['ok' => true]);
Yii::$app->response->format = Response::FORMAT_JSON; return $data;
// flash + сохранение
if ($model->load(Yii::$app->request->post()) && $model->save()) {
Yii::$app->session->setFlash('success', 'Сохранено');
return $this->redirect(['index']);
}
// ошибки
throw new \yii\web\NotFoundHttpException('Не найдено');
throw new \yii\web\ForbiddenHttpException('Нет прав');
// типовой findModel
protected function findModel($id)
{
if (($model = Post::findOne(['id' => $id])) !== null) {
return $model;
}
throw new \yii\web\NotFoundHttpException('Запись не найдена.');
}Шпаргалка по Active Record#
Post::findOne($id); Post::findOne(['slug' => $slug]);
Post::findAll(['status' => 1]);
Post::find()->where(['status' => 1])->andWhere(['>', 'created_at', $ts])
->orderBy(['created_at' => SORT_DESC])->limit(10)->all();
Post::find()->with('author', 'tags')->all(); // жадная загрузка
Post::find()->joinWith('author')->where(['user.status' => 1])->all();
Post::find()->count(); Post::find()->exists(); Post::find()->asArray()->all();
Post::find()->select(['status', 'cnt' => 'COUNT(*)'])->groupBy('status')->all();
$post = new Post(['title' => 'X']); $post->save();
$post->updateAttributes(['views' => 1]); // без валидации и событий
$post->updateCounters(['views' => 1]);
Post::updateAll(['status' => 0], ['<', 'created_at', $ts]);
$post->delete(); Post::deleteAll(['status' => 0]);
$post->link('tags', $tag); $post->unlink('tags', $tag, true);Консольные команды#
php yii # список команд
php yii serve --port=8080 # встроенный сервер
php yii migrate # применить миграции
php yii migrate/create create_post_table --fields="title:string,body:text"
php yii migrate/down 1 # откатить
php yii cache/flush-all # сбросить кеши
php yii cache/flush-schema db # сбросить кеш схемы
php yii asset assets.php config/assets-prod.php
php yii message config/i18n.php # извлечь строки для перевода
php yii fixture/load "*"
php yii gii/model --tableName=post --modelClass=Post
php yii help <команда>Конфигурация: минимальный «боевой» набор#
return [
'id' => 'app',
'basePath' => dirname(__DIR__),
'language' => 'ru-RU',
'timeZone' => 'UTC',
'bootstrap' => ['log'],
'components' => [
'request' => [
'cookieValidationKey' => getenv('COOKIE_KEY'),
'parsers' => ['application/json' => 'yii\web\JsonParser'],
],
'db' => [
'class' => 'yii\db\Connection',
'dsn' => getenv('DB_DSN'),
'username' => getenv('DB_USER'),
'password' => getenv('DB_PASS'),
'charset' => 'utf8mb4',
'enableSchemaCache' => !YII_DEBUG,
],
'cache' => ['class' => 'yii\caching\FileCache'],
'user' => ['identityClass' => 'app\models\User', 'enableAutoLogin' => true],
'session' => ['class' => 'yii\web\DbSession'],
'errorHandler' => ['errorAction' => 'site/error'],
'urlManager' => [
'enablePrettyUrl' => true,
'showScriptName' => false,
'rules' => [],
],
'log' => [
'traceLevel' => YII_DEBUG ? 3 : 0,
'targets' => [['class' => 'yii\log\FileTarget', 'levels' => ['error', 'warning']]],
],
],
'params' => require __DIR__ . '/params.php',
];Структура проекта (basic)#
basic/
commands/ консольные команды
config/ web.php, console.php, db.php, params.php
controllers/ контроллеры
mail/ представления писем
migrations/ миграции
models/ модели, формы, AR
runtime/ логи, кеш (на запись)
tests/ тесты
vendor/ зависимости Composer
views/ представления + layouts/
web/ ПУБЛИЧНЫЙ корень: index.php, assets/, css/, js/
yii консольная точка входаСоглашения об именовании#
| Сущность | Правило | Пример |
|---|---|---|
| Контроллер | <Имя>Controller в app\controllers | post-comment → PostCommentController |
| Действие | action<Имя> | create-comment → actionCreateComment() |
| Представление | views/<controller-id>/<view>.php | views/post-comment/index.php |
| Маршрут | [модуль/]контроллер/действие | admin/post/update |
| Таблица ↔ AR | camel2id, {{%prefix}} | OrderItem → order_item |
| Миграция | m<YYMMDD_HHMMSS>_<name> | m150101_185401_create_news_table |
| Asset bundle | класс-наследник AssetBundle | app\assets\AppAsset |
Мини-словарь#
- alias — строка с
@вместо пути/URL (@app,@web). - component — объект-наследник
Componentсо свойствами, событиями и поведениями. - application component — компонент в реестре
Yii::$app(ленивый синглтон). - behavior — «примесь»: добавляет объекту свойства/методы и слушает его события.
- attribute — свойство модели с данными (бизнес-логика).
- asset / bundle — файл ресурса / набор файлов с зависимостями.
- module — мини-приложение внутри приложения (свои MVC).
- scenario — режим модели, определяющий набор активных и безопасных атрибутов.
- fixture — фиксированный набор данных для повторяемых тестов.
Если пришли из Yii 1.1#
| Yii 1.1 | Yii 2.0 |
|---|---|
CComponent | yii\base\BaseObject (свойства) + yii\base\Component (+ события, поведения) |
CWebApplication, Yii::app() | yii\web\Application, Yii::$app |
CActiveRecord::model()->findAll() | Post::find()->all() |
relations() | геттеры getOrders() с hasMany()/hasOne() |
CDbCriteria | yii\db\ActiveQuery / yii\db\Query |
$this в виде = контроллер | $this = объект View, контроллер — $this->context |
renderPartial() в виде | echo $this->render() (возвращает строку) |
фильтры filters() | поведения behaviors() + yii\base\ActionFilter |
CWebUser, CUserIdentity | yii\web\User + yii\web\IdentityInterface |
| пакеты скриптов | asset bundles (yii\web\AssetBundle) |
| scopes | свои классы ActiveQuery с методами-фильтрами |
unsafe валидатор | scenarios() и префикс ! |
Куда смотреть дальше#
- Полное руководство: https://www.yiiframework.com/doc/guide/2.0/ru
- Описание классов (API): https://www.yiiframework.com/doc/api/2.0
- Расширения: https://www.yiiframework.com/extensions
- Форум и чат сообщества: https://forum.yiiframework.com, Telegram/Gitter
yiisoft/yii2