Useful Tips: How to Work With Yii2 Framework Effectively
Yii1 was developed in 2008 and quickly became popular due to it's simplicity and speed. However, at the moment it is outdated compared to other frameworks: it doesn't use namespace or composer. Thus, Yii2 was developed a completely new framework that has received great improvements with regard to the first version. In this article, I will talk about the main differences of Yii2 from Yii1, as well as examples of using it's components.
Main advantages:
- improved performance
- namespace and composer are used
- built-in panel for debugging
- ActiveRecord was completely rewritten
Unfortunately, the documentation is now being actively developed, so there are no answers to many common issues, such as how to use the environment, how to write efficient REST API with pagination, sorting, etc., how to write complex queries using ORM. In this article, I'll write what problems I encountered and how I solved them.
See also: How to configure versioning for Yii2
Yii installation
Despite the instructions described in the documentation, beginners can face a variety of problems. You can install Yii2 following the official instructions.
Personally I faced a problem with the composer: errors from Github returned. The problem is that Github has a limited number of requests for a non-authorized user, and when the composer requests data from Github, there may be an error that the limit is exceeded. To solve the problem, it is necessary to create a permanent token on Github that will be used for queries. Read more here.
Use of Environment
After installation, we have to choose environment
Environment replaces the entire contents of the project folder with the contents of the folder "/environments". The file structure is made so that it is impossible to use a common configuration. It's easy to fix.
We divide the configuration into files: main, main-local, main-env. We are going to make the same structure for params. Inheritance of parameters looks like:
main -> main-env -> main-local. I.e. main-local will overwrite the previous values.
Main.php file will look the following way:
<?php
$params = array_merge(
require(__DIR__ . '/../../common/config/params.php'),
file_exists(__DIR__ . '/../../common/config/params-env.php') ? require(__DIR__ . '/../../common/config/params-env.php') : [],
file_exists(__DIR__ . '/../../common/config/params-local.php') ? require(__DIR__ . '/../../common/config/params-local.php') : [],
require(__DIR__ . '/params.php'),
file_exists(__DIR__ . '/params-env.php') ? require(__DIR__ . '/params-env.php') : [],
file_exists(__DIR__ . '/params-local.php') ? require(__DIR__ . '/params-local.php') : []
);
We make this structure in the folder environments:
After that, when choosing the environment only the files main-env and params-env will change.
Using of Expression, Query, With for complex queries
In Yii2 all requests are made using the Query class. It completely replaces CDBCriteria and offers a more versatile interface for building requests. This interface replaces the scopes in Yii1, and this may cause difficulties, but they are solved. For example default scope, you can do this way:
class Customer extends ActiveRecord
{
public static function find()
{
return (new CustomerQuery(get_called_class()))->orderBy(['created_at' => SORT_DESC]);
}
}
class CustomerQuery extends ActiveQuery
{
public function active()
{
return $this->filterWhere(['active' => 1]);
}
}
Request Customer::find() -> active() -> all() selects all the active users and sorts by the field created.
The query can also be used for a subquery instead of expression. For example:
User::find()->filterWhere(['od' => Customer::find()->select('id')->where('id > 0') ]);
After moving from Yii1, you may have difficulty with table alias in relations. Yii1 used table alias for the relations, while Yii2 does not. This is simply solved.
/**
* @return \yii\db\ActiveQuery
*/
public function getAccessToken()
{
return $this->hasOne(AccessToken::className(), ['user_id' => 'id'])->from(['accessToken' => AccessToken::tableName()]);
}
When you use the relations accessToken, in an SQL request your table will also be called accessToken.
Also in Yii2 the syntax of requests to the attached notes building has changed. For a beginner it would be difficult to understand how to make it, so I leave an example here:
class MemberQuery extends ActiveQuery
{
public function byProgramType($type = ProgramType::TYPE_INNER_CIRCLE)
{
return $this->innerJoinWith([
'program' => function(ActiveQuery $query) use ($type) {
$query->innerJoinWith([
'type' => function(ActiveQuery $query) use ($type) {
$query->andFilterWhere(['type' => $type]);
}
]);
}
]);
}
}
In this example, I filter the data model Member by a nested relation program.type. I am further requesting the relation 'program', after that I'm requesting relation 'type', and filter by the parameter type.
REST API implementation
Yii2 has a ready-made REST API with resources using. But it seems to me inconvenient, because it is very difficult to add validation roles, add the action, or change the logic.
I created a basic controller which has it's own methods for sorting, pagination, filtration, getting nested records.
For example, I can create a request
/api/users?name=%test%&with=comments&limit=10&offset=10
To process such a query, I created the following action:
public function actionIndex()
{
$models = User::find();
$this->addPagination($models);
$this->addFilters($models, ['name' => true]);
$expand = $this->addWith($models, ['comments', 'posts']);
$this->returnQueryResult($models, $expand);
}
Due to the fact that most of the code used in REST API is in a separate function, this greatly reduced the amount of code to process the request. This approach allows you to create the set of endpoints without thinking about processing filters for each request. Also this code can easily be used in an existing project.
UserController is inherited from Controller class. The following methods are aimed to process requests.
Pagination processing (limit, offset)
public function addPagination(Query &$models)
{
$count = $models->count();
header('X-Count: ' . $count);
if (!$count) {
$models->limit(0);
}
if ($limit = \Yii::$app->request->get('limit')) $models->limit($limit);
if ($offset = \Yii::$app->request->get('offset')) $models->offset($offset);
}
This code is the simplest. It counts the number of elements and applies the parameters of the GET request.
Filters processing
/**
* Add filters from GET
*
* value will be taken from $filtersData or $_GET[column]
*
* @param Query $models
* @param array $columns
* [
* 'column' => true, // SQL: 'column = value' or 'column LIKE value' or 'column IN (value, ...)'
* 'column' => 'someTable.someColumn', // alias for column. Example: 'column = value' will be 'someTable.someColumn = value'
* '%column%' => <true|columnAlias>, // wrap value with "%". Example: '%column%' => true, SQL: 'column = LIKE "%value%"'
* '%column%' => new Expression('someColumn = DATE(NOW()) AND column LIKE :column') // add expression in where condition
* ]
*
* @param array $filtersData optional, values for filters. If not set, values will be taken from $_GET
*/
public function addFilters(Query &$models, $columns = [], $filtersData = null, $forOne = null)
{
$tableName = $models->from ? (!empty($models->from[0]) ? $models->from[0] : key($models->from)) . '.' : '';
if (is_null($filtersData)) $filtersData = \Yii::$app->request->get();
if (!empty($filtersData['one']) && is_array($forOne) && !array_intersect($forOne, array_keys(\Yii::$app->request->get()))) {
$models->where('1=0');
$models->params([]);
return;
}
/**
* @var string $name
* @var Expression|string $expression
*/
foreach($columns as $name => $expression) {
$normalName = str_replace('%', '', $name);
$hasPercent = strlen($normalName) != strlen($name);
if (isset($filtersData[$normalName]) && !is_null($filtersData[$normalName]) && $filtersData[$normalName] !== '') {
$value = $filtersData[$normalName];
$value = $hasPercent ? '%' . $value . '%' : $value;
if (strpos($value, ',') !== false) {
$value = explode(',', $value);
}
if ($expression === true) {
$models = $models->andFilterWhere([$tableName . $normalName => $value]);
} else if ($expression instanceof Expression) {
$models = $models->andWhere($expression->expression, array_merge($expression->params, [':' . $normalName => $value]));
} else {
$models = $models->andWhere([$expression => $value]);
}
}
}
}
It is the biggest part. Here we specify the parameters for filtering ($column) and data for sampling ($filtersData). Filters are described in the following form:
@param array $columns
[
'column' => true, // SQL: 'column = value' or 'column LIKE value' or 'column IN (value, ...)'
'column' => 'someTable.someColumn', // alias for column. Example: 'column = value' will be 'someTable.someColumn = value'
'%column%' => <true|columnAlias>, // wrap value with "%". Example: '%column%' => true, SQL: 'column = LIKE "%value%"'
'%column%' => new Expression('someColumn = DATE(NOW()) AND column LIKE :column') // add expression in where condition
]
column parameter is something that comes from GET, value will be in Query.
Nested data processing
/**
* Add nested data
*
* @param Query $models
* @param $allowedWithArray
* @param null $with
* @param array $withColumns ['withName' => ['<GET Param Name>', <GET Param Name>', ... ], ...]
* @return array
*/
public function addWith(Query &$models, $allowedWithArray, $with = null, $withColumns = [])
{
$expand = [];
if ($with === null) $with = \Yii::$app->request->get('with', []);
if (is_string($with)) $with = explode(',', $with);
$allowedWithExp = str_replace('()', '', '/(' . join(')|(', $allowedWithArray) . ')/');
foreach ($withColumns as $withName => $columns) {
if ((array_intersect(array_keys(\Yii::$app->request->get()), $columns) || in_array(\Yii::$app->request->get('sort'), $columns)) && !in_array($withName, $with)) {
$with[] = $withName;
}
}
foreach($with as $withItem) {
if (preg_match($allowedWithExp, $withItem)) {
// try to call $model->withSomeRelation()
if (method_exists($models, 'with' . ucfirst($withItem))) {
call_user_func([$models, 'with' . ucfirst($withItem)]);
} else {
$models->joinWith($withItem, true, 'LEFT JOIN');
}
$expandItem = explode('.', $withItem);
$expand[] = reset($expandItem);
}
}
return $expand;
}
Here we process GET parameter with. In $allowedWithArray we pass nested data that we are allowed to request, for example: ['comments', 'post.commentsCount']. In $with itself nested data come that we want to ask.
Everything is combined into a single SQL query using LEFT JOIN that allows you to filter by nested records.
Despite the fact that the code may seem large and complex, this implementation reduces development time and the amount of code. All methods may be used alone, or not to be used at all. This gives a lot of flexibility for writing REST API.
To summarize, Yii2 turned out to a very well-balanced framework. It is more flexible and faster than YII1. It allows writing any component easily, which actually I did. Among the few drawbacks of the framework, I would mention the absence of DI (dependency injection), but perhaps this is the reason of it's ease of use. Having any questions? You are welcome to text our managers, we will be glad to help!
Comments