Difference between #25 and #261 of
Yii v2 snippet guide

Changes

Title changed

Yii v2 for beginnerssnippet guide

Category unchanged

Tutorials

Yii version unchanged

2.0

Tags unchanged

tutorial,beginner,yii2

Content changed

**Please give me a few days to add whole text**
 
 
Intro
 
-----
 
Skip this paragraph if you are in hurry :-) ... 8 years ago I started these two tutorials for Yii 1:
 
- [https://www.yiiframework.com/wiki/250/yii-for-beginners](https://www.yiiframework.com/wiki/250/yii-for-beginners)
 
- [https://www.yiiframework.com/wiki/462/yii-for-beginners-2](https://www.yiiframework.com/wiki/462/yii-for-beginners-2)
 
 
... and today I am beginning with Yii 2 so I will also gather my snippets and publish them here so we all can quickly setup the yii-basic-demo. 
 
 
I have some experiences with Yii 1, but I havent used Yii for almost 5 years so many things are new for me again. Plus I was suprised that the Yii 2 demo application does not contain some basic functionalities (like DB login, translations etc) which must be implemented in the most of web projects so I will focus on them. Plus I will talk about GitLab. 
 
 
Prerequisities
 
---
 
Skip this paragraph if you know how to run your Yii demo project...
 
 
I work with Win10 + [XAMPP Server](https://www.apachefriends.org/download.html) so I will expect this configuration. Do not forget to start the server and enable Apache + MySQL in the dialog. Then test that following 2 URLs work for you
 
 
- [http://localhost/](http://localhost/)
 
- [http://localhost/phpmyadmin/](http://localhost/phpmyadmin/)
 
 
You should also download the [Yii basic demo application](https://www.yiiframework.com/download) and place it into the **htdocs** folder. In my case it is here:
 
 
- C:\xampp\htdocs
 
 
And your index.php should be here:
 
 
- C:\xampp\htdocs\basic\web\index.php
 
 
If you set thing correcly up, following URL will open your demo application. Now it will probably throw an exception:
 
 
- [http://localhost/basic/web/](http://localhost/basic/web/)
 
 
The Exception is removed by enterinng any text into attribute 'cookieValidationKey' in file:
 
 
- C:\xampp\htdocs\basic\config\web.php
 
 
Dont forget to connect Yii to the DB. It is done in file:
 
 
- C:\xampp\htdocs\basic\config\db.php
 
 
... but it should work out of the box if you use DB name "yii2basic" which is used below ...
 
 
Yii demo app + GitLab
 
---
 
... text ...
 
 
Translations (i18n) + changing languages
 
---
 
... text ...
 
 
User management + basic SQL commands
 
---
 
To create DB with users, use following command. I recommend charset **utf8_unicode_ci** (or utf8mb4_unicode_ci) as it allows you to use [more international characters](https://stackoverflow.com/questions/766809/whats-the-difference-between-utf8-general-ci-and-utf8-unicode-ci).
 
 
```MySQL
 
CREATE DATABASE IF NOT EXISTS `yii2basic` DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci;
 
 
CREATE TABLE IF NOT EXISTS `user` (
 
  `id` INT NOT NULL AUTO_INCREMENT,
 
  `username` VARCHAR(45) NOT NULL,
 
  `password` VARCHAR(60) NOT NULL,
 
  PRIMARY KEY (`id`))
 
ENGINE = InnoDB;
 
 
INSERT INTO `user` (`id`, `username`, `password`) VALUES (NULL, 'user01', '0497fe4d674fe37194a6fcb08913e596ef6a307f');
 
```
 
 
If you must use MyISAM instead of InnoDB, just change the word InnoDB into MYISAM.
 
 
Then use GII to generate model, views and controller. The URL will probably be
 
- [http://localhost/basic/web/index.php?r=gii](http://localhost/basic/web/index.php?r=gii).
 
 
Login via database + Session
 
---
 
... text ...
 
 
Access rights
 
---
 
... text ...
 
## My articles
 
Articles are separated into more files as there is the max lenght for each file on wiki.
 
 
* [Yii v1 for beginners](https://www.yiiframework.com/wiki/250/yii-for-beginners)
 
* [Yii v1 for beginners 2](https://www.yiiframework.com/wiki/462/yii-for-beginners-2)
 
* [Yii v2 snippet guide I](https://www.yiiframework.com/wiki/2552/yii-v2-snippet-guide)
 
* [Yii v2 snippet guide II](https://www.yiiframework.com/wiki/2558/yii-v2-snippet-guide-ii)
 
* [Yii v2 snippet guide III](https://www.yiiframework.com/wiki/2567/yii-v2-snippet-guide-iii)
 
* [Začínáme s PHP frameworkem Yii2 (I) česky - YouTube](https://youtu.be/ub06hNoL8B8)
 
 
**Intro**
 
---
 
Hi all!
 
 
This snippet guide works with the basic Yii demo application and enhances it. It continues in my series of simple Yii tutorials. Previous two contain basic info about MVC concept, exporting to Excel and other topics so read them as well, but they are meant for Yii v1. 
 
 
... and today I am beginning with Yii 2 so I will also gather my snippets and publish them here so we all can quickly setup the yii-basic-demo just by copying and pasting. This is my goal - to show how-to without long descriptions.
 
 
If you find any problems in my snippets, let me know, please.
 
 
**Prerequisities**
 
---
 
Skip this paragraph if you know how to run your Yii demo project...
 
 
I work with Win10 + [XAMPP Server](https://www.apachefriends.org/download.html) so I will expect this configuration. Do not forget to start the server and enable Apache + MySQL in the dialog. Then test that following 2 URLs work for you
 
 
- [http://localhost/](http://localhost/)
 
- [http://localhost/phpmyadmin/](http://localhost/phpmyadmin/)
 
 
You should also download the [Yii basic demo application](https://www.yiiframework.com/download) and place it into the **htdocs** folder. In my case it is here:
 
 
- C:\xampp\htdocs
 
 
And your index.php should be here:
 
 
- C:\xampp\htdocs\basic\web\index.php
 
 
If you set things correctly up, following URL will open your demo application. Now it will probably throw an exception:
 
 
- [http://localhost/basic/web/](http://localhost/basic/web/)
 
 
**The Exception is removed by entering any text into attribute 'cookieValidationKey' in file**:
 
 
- C:\xampp\htdocs\basic\config\web.php
 
 
Dont forget to connect Yii to the DB. It is done in file:
 
 
- C:\xampp\htdocs\basic\config\db.php
 
 
... but it should work out-of-the-box if you use DB name "yii2basic" which is also used in examples below ...
 
 
.
 
 
.
 
 
**Yii demo app + GitLab**
 
---
 
 
Once you download and run the basic app, I recommend to push it into [GitLab](https://gitlab.com/). You will probably need a SSH certificate which can be generated [like this](https://www.huber.xyz/?p=275) using [PuTTYgen](https://www.puttygen.com/) or command "ssh-keygen" in Windows10. When I work with Git I use [TortoiseGIT](https://www.puttygen.com/) which integrates all git functionalities into the context menu in Windows File Explorer.
 
 
First go to GitLab web and [create a new project](https://gitlab.com/projects/new). Then you might need to fight a bit, because the process of connecting your PC to GIT seems to be quite complicated. At least for me.
 
 
Note: [Here](https://gitlab.com/-/profile/keys) you can add the public SSH key to GitLab. Private key must be named "id_rsa" and stored in Win10 on path C:\\Users\\{username}\\.ssh\\id_rsa
 
 
Once things work, just create an empty folder, right click it and select Git Clone. Enter your git path, best is this format:
 
 
- git@gitlab.com:{username}/{projectName}.git
 
- or you can use also this URL:
 
- https://gitlab.com/{username}/{projectName}.git
 
- or you can use HTTP:
 
- http://gitlab.com/{username}/{projectName}.git
 
 
 
Note: What works for me the best is using the following command to clone my project and system asks me for the password. Other means of connection usually refuse me. Then I can start using TortoiseGIT.
 
 
```
 
git clone https://{username}@gitlab.com/{username}/{myProjectName}.git
 
```
 
 
When cloned, copy the content of the "basic" folder into the new empty git-folder and push everything except for folder "vendor". (It contains 75MB and 7000 files so you dont want to have it in GIT)
 
 
Then you can start to modify you project, for example based on this "tutorial".
 
 
Thanks to .gitignore files only 115 files are uploaded. Te vendor-folder can be recreated using command
 
```
 
composer install
 
```
 
which only needs file **composer.json** to exist.
 
 
 
**Automatical copying from GitLab to FTP**
 
---
 
 
I found these two pages where things are explained: [link](https://www.savjee.be/2019/04/gitlab-ci-deploy-to-ftp-with-lftp/) [link](https://stackoverflow.com/questions/49632077/use-gitlab-pipeline-to-push-data-to-ftpserver).
 
 
You need to create file .gitlab-ci.yml in the root of your repository with following content. It will fire a Pipeline job on commit using "LFTP client" automatically. If you want to do it manually, add "when:manual", see below.
 
 
```
 
variables:
 
  HOST: "ftp url"
 
  USERNAME: "user"
 
  PASSWORD: "password"
 
  TARGETFOLDER: "relative path if needed, or just ./"
 
 
deploy:
 
  script:
 
    - apt-get update -qq && apt-get install -y -qq lftp
 
    - lftp -c "set ftp:ssl-allow no; open -u $USERNAME,$PASSWORD $HOST; mirror -Rnev ./ $TARGETFOLDER --ignore-time --parallel=10 --exclude-glob .git* --exclude .git/ --exclude vendor --exclude web/assets --exclude web/index.php --exclude web/index-test.php --exclude .gitlab-ci.yml" 
 
  only:
 
    - master
 
  when: manual
 
```
 
 
I just added some exclusions (see the code) and will probably add **--delete** in the future. Read linked webs.
 
 
**Important info:** Your FTP server might block foreign IPs. If this happens, your transfer will fail with error 530. You must findout GitLab's IPs and whitelist them. [This link]( https://docs.gitlab.com/ee/user/gitlab_com/#ip-range) might help.
 
 
**User management + DB creation + login via DB**
 
---
 
To create DB with users, use following command. I recommend charset **utf8_unicode_ci** (or utf8mb4_unicode_ci) as it allows you to use [more international characters](https://stackoverflow.com/questions/766809/whats-the-difference-between-utf8-general-ci-and-utf8-unicode-ci).
 
 
```sql
 
CREATE DATABASE IF NOT EXISTS `yii2basic` DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci;
 
 
USE `yii2basic`;
 
 
CREATE TABLE IF NOT EXISTS `user` (
 
  `id` INT NOT NULL AUTO_INCREMENT,
 
  `username` VARCHAR(45) NOT NULL,
 
  `password` VARCHAR(60) NOT NULL,
 
  `email`    VARCHAR(60) NOT NULL,
 
  `authKey`  VARCHAR(60),
 
  PRIMARY KEY (`id`))
 
ENGINE = InnoDB;
 
 
INSERT INTO `user` (`id`, `username`, `password`, `email`, `authKey`) VALUES (NULL, 'user01', '0497fe4d674fe37194a6fcb08913e596ef6a307f', 'user01@gmail.com', NULL);
 
```
 
 
If you must use MyISAM instead of InnoDB, just change the word InnoDB into MYISAM.
 
 
Then replace existing model User with following snippet
 
 
- The model was generated by Gii and originally had 3 methods: tableName(), rules(), attributeLabels()
 
- In order to use the DB for login, we needed to implement IdentityInterface which requires [5 new methods](https://www.yiiframework.com/doc/api/2.0/yii-web-identityinterface). 
 
- Plus we add 2 methods because of the default LoginForm and 1 validator.
 
 
 
```php
 
<?php
 
 
namespace app\models;
 
 
use Yii;
 
 
class User extends \yii\db\ActiveRecord implements \yii\web\IdentityInterface {
 
 
    // When user detail is being edited we will only modify attribute password_new
 
    // Why? We dont want to load password-hash from DB and display it to the user
 
    // We only want him to see empty field and if it is filled in, password is changed on background
 
    public $password_new;
 
    public $password_new_repeat;
 
 
    // Use this scenario in UserController->actionCreate() right after: $model = new User() like this:
 
    // $model->scenario = User::SCENARIO_CREATE;
 
    // This will force the user to enter the password when new user is created
 
    // When user is edited, new password is not needed
 
    const SCENARIO_CREATE = "user-create";
 
 
    // ----- Default 3 model-methods by GII:
 
 
    public static function tableName() {
 
        return 'user';
 
    }
 
 
    public function rules() {
 
        return [
 
            [['username', 'email'], 'required'],
 
            [['password_new_repeat', 'password_new'], 'required', "on" => self::SCENARIO_CREATE],
 
            [['username', 'email'], 'string', 'max' => 45],
 
            ['email', 'email'],
 
            [['password', 'authKey'], 'string', 'max' => 60],
 
            [['password', 'password_new_repeat', 'password_new'], 'safe'],
 
            ['password_new_repeat', 'compare', 'operator' => '==', 'compareAttribute' => 'password_new'],
 
            ['password_new', 'compare', 'operator' => '==', 'compareAttribute' => 'password_new_repeat'],
 
            
 
            ['password_new_repeat', 'setPasswordWhenChanged'],
 
        ];
 
    }
 
 
    public function attributeLabels() {
 
        return [
 
            'id' => Yii::t('app', 'ID'),
 
            'username' => Yii::t('app', 'Username'),
 
            'password' => Yii::t('app', 'Password'),
 
            'password_new' => Yii::t('app', 'New password'),
 
            'password_new_repeat' => Yii::t('app', 'Repeat new password'),
 
            'authKey' => Yii::t('app', 'Auth Key'),
 
            'email' => Yii::t('app', 'Email'),
 
        ];
 
    }
 
 
    // ----- Password validator
 
 
    public function setPasswordWhenChanged($attribute_name, $params) {
 
 
        if (trim($this->password_new_repeat) === "") {
 
            return true;
 
        }
 
 
        if ($this->password_new_repeat === $this->password_new) {
 
            $this->password = sha1($this->password_new_repeat);
 
        }
 
 
        return true;
 
    }
 
 
    // ----- IdentityInterface methods:
 
 
    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->authKey;
 
    }
 
 
    public function validateAuthKey($authKey) {
 
        return $this->authKey === $authKey;
 
    }
 
 
    // ----- Because of default LoginForm:
 
 
    public static function findByUsername($username) {
 
        return static::findOne(['username' => $username]);
 
    }
 
 
    public function validatePassword($password) {
 
        return $this->password === sha1($password);
 
    }
 
 
}
 
 
```
 
 
Validators vs JavaScript:
 
- There are 2 types of validators. All of them are used in method **rules**, but as you can see, the validator **setPasswordWhenChanged** is my custom validator and needs a special method. *(I just abused a validator to set the password value, no real validation happens inside)*
 
- If a validator does not need this special method, it is automatically converted into JavaScript and is used on the web page when you are typing.
 
- If a validator needs the method, it cannot be converted into JavaScript so the rule is checked  only in the moment when user sends the form to the server - after successful JavaScript validation.
 
 
Now you can also create **CRUD** for the User model using GII:
 
 
- [http://localhost/basic/web/index.php?r=gii](http://localhost/basic/web/index.php?r=gii).
 
 
CRUD = Create Read Update Delete = views and controller. On the GII page enter following values:
 
 
- Model Class = app\models\User
 
- Search Model Class = app\models\UserSearch
 
- Controller Class = app\controllers\UserController
 
- View Path can be empty or you can set: views\user
 
- Again enable i18n
 
 
And then you can edit users on this URL: [http://localhost/basic/web/index.php?r=user](http://localhost/basic/web/index.php?r=user) ... but it is not all. You have to modify the view-files so that correct input fields are displayed!
 
 
Open folder views\user and do following:
 
 
- \_form.php - rename input **password** to **password_new** then duplicate it and rename to **password_new_repeat**. Remove **authKey**.
 
- \_search.php - remove **password** and **authKey**.
 
- index.php - remove **password** and **authKey**.
 
- view.php - remove **password** and **authKey**.
 
 
Plus do not forget to use the new scenario in UserController->actionCreate() like this:
 
 
```php
 
public function actionCreate()
 
{
 
  $model = new User();
 
  $model->scenario = User::SCENARIO_CREATE; // the new scenario!
 
  // ...
 
```
 
 
.
 
 
.
 
 
**i18n translations**
 
---
 
Translations are fairly simple, but I probably didnt read manuals carefully so it took me some time. Note that now I am only describing translations which are saved in files. I do not use DB translations yet. Maybe later.
 
 
**1 - Translating short texts and captions**
 
 
First create following folders and file. 
 
 
- "C:\xampp\htdocs\basic\messages\cs-CZ\app.php"
 
 
*(Note that cs-CZ is for Czech Lanuage. For German you should use de-DE etc. Use any other language if you want.)*
 
 
The idea behind is that in the code there are used only English texts and if you want to change from English to some other language this file will be used.
 
 
Now go to file config/web.php, find section "components" and paste the i18n section:
 
 
```php
 
    'components' => [
 
        'i18n' => [
 
          'translations' => [
 
            '*' => [
 
              'class' => 'yii\i18n\PhpMessageSource',
 
              'basePath' => '@app/messages',
 
              'sourceLanguage' => 'en-US',
 
              'fileMap' => [
 
                'app' => 'app.php'
 
              ],
 
            ],
 
          ],
 
        ], // end of 'i18n'
 
 
        // ... other configurations
 
 
    ], // end of 'components'
 
    
 
```
 
 
Explanation of the asterisk * can be found in article [https://www.yiiframework.com/doc/guide/2.0/en/tutorial-i18n](https://www.yiiframework.com/doc/guide/2.0/en/tutorial-i18n)
 
 
You surely saw that in views and models there are translated-texts saved like this:
 
 
```php
 
Yii::t('app', 'New password'),
 
```
 
 
It means that this text belongs to category "app" and its English version (and also its ID) is "New password". So this ID will be searched in the file you just created. In my case it was the Czech file:
 
 
- "C:\xampp\htdocs\basic\messages\cs-CZ\app.php"
 
 
Therefore open the file and paste there following code:
 
 
```php
 
<?php
 
return [
 
    'New password' => 'Nové heslo',
 
];
 
?>
 
```
 
 
Now you can open the page for adding a new user and you will see than so far nothing changed :-)
 
 
- [http://localhost/basic-/web/index.php?r=user%2Fcreate](http://localhost/basic-/web/index.php?r=user%2Fcreate)
 
 
We must change the language ... For now let's do it in a primitive and permanent way again in file config/web.php
 
 
```php
 
$config = [
 
    // use your language
 
    // also accessible via Yii::$app->language
 
    'language' => 'cs-CZ',
 
    
 
    // This attribute is not necessary.
 
    // en-US is default value
 
    'sourceLanguage' => 'en-US',
 
    
 
    // ... other configs
 
```
 
 
**2 - Translating long texts and whole views**
 
 
If you have a view with long texts and you want to translate it into a 2nd language, it is not good idea to use the previous approach, because it uses the English text as the ID. 
 
 
It is better to translate the whole view. How? ... Just create a sub-folder next to the view and give it name which will be identical to the target-lang-ID. In my case the 2nd language is Czech so I created following folder and copied my view in it. So now I have 2 identical views with identical names:
 
 
- "C:\xampp\htdocs\basic\views\site\about.php" ... English
 
- "C:\xampp\htdocs\basic\views\site\cs-CZ\about.php" ... Czech
 
 
Yii will automatically use the Czech version if needed.
 
 
.
 
 
.
 
 
**Switching languages + session + lang-dropdown in the top menu**
 
---
 
First lets add to file config/params.php attributes with list of supported languages:
 
 
```php
 
<?php
 
return [
 
    // ...
 
    'allowedLanguages' => [
 
        'en-US' => "English",
 
        'cs-CZ' => "Česky",
 
    ],
 
    'langSwitchUrl' => '/site/set-lang',
 
];
 
 
```
 
 
This list can be displayed in the main menu. Edit file:
 
 
- C:\xampp\htdocs\basic\views\layouts\main.php
 
 
And above the Nav::widget add few rows:
 
 
```php
 
    $listOfLanguages = [];
 
    $langSwitchUrl = Yii::$app->params["langSwitchUrl"];
 
    foreach (Yii::$app->params["allowedLanguages"] as $langId => $langName) {
 
        $listOfLanguages[] = ['label' => Yii::t('app', $langName), 'url' => [$langSwitchUrl, 'langID' => $langId]];
 
    }
 
```
 
 
and then add one item into Nav::widge
 
 
```php
 
    echo Nav::widget([
 
        // ...
 
        'items' => [
 
            // ...
 
            ['label' => Yii::t('app', 'Language'),'items' => $listOfLanguages],
 
            // ...
 
```
 
 
Now in the top-right corner you can see a new drop-down-list with list of 2 languages. If one is selected, action "site/setLang" is called so we have to create it in SiteController.
 
 
*Note that this approach will always redirect user to the new action and his work will be lost. Nevertheless this approach is very simple so I am using it in small projects. More complex projects may require an ajax call when language is changed and then updating texts using javascript so reload is not needed and user's work is preserved. But I expect that when someone opens the web, he/she sets the language immediately and then there is no need for further changes.*
 
 
The setLang action looks like this:
 
 
```php
 
    public function actionSetLang($langID = "") {
 
        $allowedLanguages = Yii::$app->params["allowedLanguages"];
 
        $langID = trim($langID);
 
        if ($langID !== "" && array_key_exists($langID, $allowedLanguages)) {
 
            Yii::$app->session->set('langID', $langID);
 
        }
 
        return $this->redirect(['site/index']);
 
    }
 
```
 
 
As you can see when the language is changed, redirection to site/index happens. Also mind that we are not modifying the attribute from config/web.php using Yii::$app->language, but we are saving the value into the session. The reason is that PHP deletes memory after every click, only session is kept. 
 
 
We then can use the langID-value in other controllers using new method beforeAction:
 
 
```php
 
    public function beforeAction($action) {
 
 
        if (!parent::beforeAction($action)) {
 
            return false;
 
        }
 
 
        Yii::$app->language = Yii::$app->session->get('langID');
 
 
        return true;
 
    }
 
```
 
 
.. or you can create one parent-controller named for example BaseController. All other controllers will extend it. 
 
 
```php
 
<?php
 
 
namespace app\controllers;
 
 
use Yii;
 
use yii\web\Controller;
 
 
class BaseController extends Controller {
 
 
    public function beforeAction($action) {
 
 
        if (!parent::beforeAction($action)) {
 
            return false;
 
        }
 
 
        Yii::$app->language = Yii::$app->session->get('langID');
 
 
        return true;
 
    }
 
 
}
 
 
```
 
 
As you can see in the snippet above, other controllers must contain row "use app\controllers\BaseController" + "extends BaseController".
 
 
**Formatting values based on your Locale **
 
---
 
Go to config\web.php and add following values:
 
 
```php
 
$config = [
 
  // ..
 
 'language' => 'cs-CZ', 
 
 // \Yii::$app->language: 
 
 // https://www.yiiframework.com/doc/api/2.0/yii-base-application#$language-detail
 
//..
 
 'components' => [
 
  'formatter' => [
 
   //'locale' => 'cs_CZ', 
 
   // Only effective when the "PHP intl extension" is installed else "language" above is used: 
 
   // https://www.php.net/manual/en/book.intl.php
 
 
   //'language' => 'cs-CZ', 
 
   // If not set, "locale" above will be used:
 
   // https://www.yiiframework.com/doc/api/2.0/yii-i18n-formatter#$language-detail
 
      
 
   // Following values might be usefull for your situation:
 
   'booleanFormat' => ['Ne', 'Ano'],
 
   'dateFormat' => 'yyyy-mm-dd', // or 'php:Y-m-d'
 
   'datetimeFormat' => 'yyyy-mm-dd HH:mm:ss', // or 'php:Y-m-d H:i:s'
 
   'decimalSeparator' => ',',
 
   'defaultTimeZone' => 'Europe/Prague',
 
   'thousandSeparator' => ' ',
 
   'timeFormat' => 'php:H:i:s', //  or HH:mm:ss
 
   'currencyCode' => 'CZK',
 
  ],
 
```
 
 
In GridView and DetailView you can then use following and your settings from above will be used:
 
 
```php
 
'columns' => [
 
 [
 
  'attribute' => 'colName',
 
  'label' => 'Value',
 
  'format'=>['decimal',2]
 
 ],
 
 [
 
   'label' => 'Value', 
 
   'value'=> function ($model) { return \Yii::$app->formatter->asDecimal($model->myCol, 2) . ' EUR' ; } ],
 
 ]
 
 // ...
 
]
 
```
 
 
PS: I do not use currency formatter as it always puts the currency name before the number. For example USD 123. But in my country we use format: 123 CZK.
 
 
More links on this topic:
 
- [yii\i18n\Formatter](https://www.yiiframework.com/doc/api/2.0/yii-i18n-formatter)
 
- [Examples](https://yii2-framework.readthedocs.io/en/stable/guide/output-formatter)
 
- [More examples](https://stackoverflow.com/questions/27078178/yii2-number-format)
 
 
**Simple access rights**
 
---
 
Every controller can allow different users/guests to use different actions. Method behaviors() can be used to do this. If you generate the controller using GII the method will be present and you will just add the "access-part"  like this:
 
 
```php
 
 
// don't forget to add this import:
 
use yii\filters\AccessControl;
 
 
public function behaviors() {
 
  return [
 
    // ...
 
    'access' => [
 
      'class' => AccessControl::className(),
 
      'rules' => [
 
        [
 
          'allow' => true,
 
          'roles' => ['@'], // logged in users
 
          // 'roles' => ['?'], // guests
 
          // 'matchCallback' => function ($rule, $action) {
 
            // all logged in users are redirected to some other page
 
            // just for demonstration of matchCallback
 
            // return $this->redirect('index.php?r=user/create');
 
          // }
 
        ],
 
      ],
 
      // All guests are redirected to site/index in current controller:
 
      'denyCallback' => function($rule, $action) {
 
        Yii::$app->response->redirect(['site/index']);
 
      },
 
    ],
 
  ];
 
}
 
```
 
 
.. This is all I needed so far. I will add more complex snippet as soon as I need it ...
 
 
Details can be found here [https://www.yiiframework.com/doc/guide/2.0/en/security-authorization](https://www.yiiframework.com/doc/guide/2.0/en/security-authorization).
 
 
.
 
 
.
 
 
**Nice URLs**
 
---
 
Just uncomment section "urlManager" in config/web.php .. htaccess file is already included in the basic demo. In case of problems see [this link](https://stackoverflow.com/questions/26525320/enable-clean-url-in-yii2).
 
 
My problem was that images were not displayed when I enabled nice URLs. Smilar discussion [here](https://stackoverflow.com/questions/39197583/image-is-not-passing-in-carousel-in-yii2). 
 
 
```php
 
// Originally I used these img-paths:
 
<img src="..\web\imgs\myimg01.jpg"/>
 
 
/// Then I had to chage them to this:
 
Html::img(Yii::$app->request->baseUrl . '/imgs/myimg01.jpg')
 
 
// The important change is using the "baseUrl"
 
```
 
 
Note that **Yii::$app->request->baseUrl** returns "/myProject/web". No trailing slash.
 
 
.
 
 
.
 
 
**How to redirect web to subfolder /web**
 
---
 
Note: If you are using the advanced demo app, [this](https://stackoverflow.com/questions/37451324/how-to-change-base-url-and-enable-prettyurl-in-yii2) link can be interesting for you.
 
 
Yii 2 has the speciality that index.php is hidden in the web folder. I didnt find in the official documentation the important info - how to hide the folder, because user is not interested in it ...
 
 
Our demo application is placed in folder:
 
- C:\xampp\htdocs\basic\web\index.php
 
 
Now you will need 2 files named .htaccess
 
- C:\xampp\htdocs\basic\web\\.htaccess
 
- C:\xampp\htdocs\basic\\.htaccess
 
 
The first one is mentioned in chapter **Nice URLs** and looks like this:
 
 
```
 
RewriteEngine on
 
RewriteCond %{REQUEST_FILENAME} !-d
 
RewriteCond %{REQUEST_FILENAME} !-f
 
RewriteRule . index.php [L]
 
```
 
 
The second is simpler:
 
 
```
 
RewriteEngine on
 
RewriteRule ^(.*)$ web/$1 [L]
 
```
 
 
... it only adds the word "web" into all URLs. But first we have to remove the word from URLs. Open file config/web.php and find section **request**. Add attribute **baseUrl**:
 
 
```
 
'request' => [
 
  // 'cookieValidationKey' => ...
 
  'baseUrl' => '/basic', // add this line
 
],
 
```
 
Now things will work for you. But it might be needed to use different value for devel and productive environment. Productive web is usually in the root-folder so baseUrl should be en empty string. I did it like this:
 
 
```php
 
$baseUrlWithoutWebFolder = "";
 
if (YII_ENV_DEV) {
 
  $baseUrlWithoutWebFolder = '/basic';
 
}
 
 
// ...
 
 
'request' => [
 
  // 'cookieValidationKey' => ...
 
  'baseUrl' => $baseUrlWithoutWebFolder,
 
],
 
 
```
 
 
I will test this and if I find problems and solutions I will add them.
 
 
.
 
 
.
 
 
**Auto redirection from login to desired URL **
 
---
 
... to be added  ...
 
 
.
 
 
.
 
 
**What to change when exporting to the Internet**
 
---
 
- Delete file web/index-test.php
 
- In file web/index.php comment you 2 first lines containing YII_DEBUG + YII_ENV
 
- Delete the text from view site/login which says "You may login with admin/admin or demo/demo."
 
 
.
 
 
.
 
 
**Saving contact inqueries into DB**
 
---
 
```sql
 
DROP TABLE IF EXISTS `contact` ;
 
 
CREATE TABLE IF NOT EXISTS `contact` (
 
  `id` INT NOT NULL AUTO_INCREMENT,
 
  `name` VARCHAR(45) NOT NULL,
 
  `email` VARCHAR(45) NOT NULL,
 
  `subject` VARCHAR(100) NOT NULL,
 
  `body` TEXT NOT NULL,
 
  PRIMARY KEY (`id`))
 
ENGINE = InnoDB;
 
```
 
 
- Create the DB table
 
- Generate Model + CRUD using GII
 
- In Site controller replace ContactForm with Contact (in section "use" and in actionContact) and in the action change the IF condition:
 
```php
 
use app\models\Contact;
 
// ... 
 
public function actionContact() {
 
    $model = new Contact();
 
    if ($model->load(Yii::$app->request->post()) && $model->save()) {
 
    // ...
 
```
 
- Open the new contact model and add one attribute and 2 rules:
 
 
```php
 
public $verifyCode;
 
// ...
 
  ['verifyCode', 'captcha'],
 
  ['email', 'email'],
 
 
// and translation for Captcha
 
'verifyCode' => Yii::t('app', 'Verification'),
 
```
 
 
- You can also delete one paragraph from view/site/contact
 
```php
 
<p>
 
Note that if you turn on the Yii debugger ...
 
```
 
 
Then some security - filtering users in the new ContactController:
 
 
```php
 
public function beforeAction($action) {
 
 
  if (!parent::beforeAction($action)) {
 
    return false;
 
  }
 
 
  $guestAllowedActions = [];
 
 
  if (Yii::$app->user->isGuest) {
 
    if (!in_array($action->actionMethod, $guestAllowedActions)) {
 
      return $this->redirect(['site/index']);
 
    }
 
  }
 
  
 
  return true;
 
}
 
```
 
 
**Tests - unit + opa**
 
---
 
... see next chapters ...
 
 
**Adding a google-like calendar**
 
---
 
 
I needed to show user a list of his events in a large calendar so I used library [fullcalendar](https://fullcalendar.io/).
 
 
Great demo which you can just copy and paste:
 
- https://fullcalendar.io/js/fullcalendar-3.0.0/demos/list-views.html
 
- ... just "view source" of the web and copy the js + html code, css and js files. 
 
 
```css
 
/*I added this style to hide vertical scroll-bars*/
 
.fc-scroller.fc-day-grid-container{
 
  overflow: hidden !important;
 
}
 
```
 
- Don't forget to use these files for example in your view like this:
 
 
```php
 
$this->registerCssFile('@web/css/fullcalendar/fullcalendar.css');
 
$this->registerCssFile('@web/css/fullcalendar/fullcalendar.print.css', ['media' => 'print']); 
 
 
$this->registerJsFile('@web/js/fullcalendar/moment.min.js', ['depends' => ['yii\web\JqueryAsset']]);
 
$this->registerJsFile('@web/js/fullcalendar/fullcalendar.min.js', ['depends' => ['yii\web\JqueryAsset']]);
 
 
// details here:
 
// https://www.yiiframework.com/doc/api/2.0/yii-web-view
 
```
 
 
... if you want to go pro, use NPM. The NPM way is described [here](https://fullcalendar.io/docs/getting-started).
 
 
API is  here:
 
https://fullcalendar.io/docs
 
... you can then enhace the calendar config from the example above
 
 
In order to make things work I had to force jQuery to be loaded before calendar scripts using file config/web.php like this
 
 
```php
 
   'components' => [
 
        
 
// ...
 

 
       'assetManager' => [
 
            'bundles' => [
 
                'yii\web\JqueryAsset' => [
 
                    'jsOptions' => [ 'position' => \yii\web\View::POS_HEAD ],
 
                ],
 
            ],
 
        ],
 
```
 
 
You can customize the calendar in many ways. For example different event-color is shown [here](https://fullcalendar.io/docs/event-colors-demo). Check the source code.
 
 
.
 
 
.
 
 
**Scenarios - UNKNOWN SCENARIO EXCEPTION**
 
---
 
I have been using scenarios a lot but today I spent 1 hour on a problem - I had 2 scenarios and one of them was just assigned to the model ...
 
 
```php
 
$model->scenario = "abc";
 
```
 
 
... but had no **rule** defined yet. I wanted to implement the rule later, but I didnt know that when you set a scenario to your model it **must** be used in method rules() or defined in method scenarios(). So take this into consideration. I expected that when the scenario has no rules it will just be skipped or deleted. 
 
 
.
 
 
.
 
 
**Richtext / wysiwyg HTML editor - Summernote**
 
---
 
If you want to allow user to enter html-formatted text, you need to use some HTML wysiwyg editor, because ordinary TextArea can only work with plain text. It seems to me that [Summernote](https://summernote.org/getting-started/#simple-example) is the simplest addon available:
 
 
```javascript
 
// Add following code to file layouts/main.php .. 
 
// But make sure jquery is already loaded !! 
 
// - Read about this topic in chapter "Adding a google-like calendar"
 
 
<!-- include summernote css/js -->
 
<link href="http://cdnjs.cloudflare.com/ajax/libs/summernote/0.8.12/summernote.css" rel="stylesheet">
 
<script src="http://cdnjs.cloudflare.com/ajax/libs/summernote/0.8.12/summernote.js"></script>
 
 
// And then in any view you can use this code:
 
 
<script>
 
$(document).ready(function() {
 
  $('#summernote1').summernote();
 
  $('#summernote2').summernote();
 
});
 
</script>
 
<div id="summernote1">Hello Summernote</div>
 
 
<form method="post">
 
  <textarea id="summernote2" name="editordata"></textarea>
 
</form>
 
 
```
 
 
On this page I showed how to save Contacts inqueries into database. If you want to use the richtext editor in this section, open view contact/\_form.php and just add this JS code:
 
 
```javascript
 
<script>
 
$(document).ready(function() {
 
  $('#contact-body').summernote();
 
});
 
</script>
 
```
 
 
It will be saved to DB as HTML code. But this might be also a source of problems, because user can inject some dangerous HTML code. So keep this in mind.
 
 
Now you will also have to modify view contact/view.php like this in order to see nice formatted text:
 
 
```php
 
DetailView::widget([
 
  'model' => $model,
 
  'attributes' => [
 
    // ...
 
    'body:html',
 
  ],
 
])
 
```
 
... to discover all possible formatters, check all asXXX() functions on [this](https://www.yiiframework.com/doc/api/2.0/yii-i18n-formatter) page: 
 
 
.
 
 
.
 
 
**SEO optimization**
 
---
 
This is not really a YII topic but as my article is some kind of a code-library I will paste it here as well.
 
To test your SEO score you can use special webs. For example [seotesteronline](https://suite.seotesteronline.com/seo-checker), but only once per day. 
 
It will show some statistics and recommend enhancements so that your web is nicely shown on FB and Twitter or found by Google.
 
 
Important are for example OG meta tags or [TWITTER meta tags](https://developer.twitter.com/en/docs/tweets/optimize-with-cards/overview/summary). They are basicly the same. Read more [here](https://css-tricks.com/essential-meta-tags-social-media/). You can test them at [iframely.com](http://debug.iframely.com).
 
 
Basic tags are following and you should place them to head:
 
- Note that Twitter is using attribute "name" instead of "property" which is defined in OG
 
- btw OG was introduced by Facebook. Twitter can process it as well, but SEO optimizers will report an error when Twitter's tags are missing.
 
 
```html
 
 
<!DOCTYPE html>
 
<html lang="<?= Yii::$app->language ?>">
 
<head>
 
 
  <meta property="og:site_name" content="European Travel, Inc.">
 
  <meta property="og:title" content="European Travel Destinations">
 
  <meta property="og:description" content="Offering tour packages for individuals or groups.">
 
  <meta property="og:image" content="http://euro-travel-example.com/thumbnail.jpg">
 
  <meta property="og:url" content="http://euro-travel-example.com/index.htm">
 
  <meta name="twitter:card" content="summary_large_image">
 
 
  <!--  Non-Essential, But Recommended -->
 
  <meta property="og:site_name" content="European Travel, Inc.">
 
  <meta name="twitter:image:alt" content="Alt text for image">
 
 
  <!--  Non-Essential, But Required for Analytics -->
 
  <meta property="fb:app_id" content="your_app_id" />
 
  <meta name="twitter:site" content="@website-username">
 
  
 
  <!-- seotesteronline.com will also want you to add these: -->
 
  <meta name="description" content="blah blah">
 
  <meta property="og:type" content="website">
 
  <meta name="twitter:title" content="blah blah">
 
  <meta name="twitter:description" content="blah blah">
 
  <meta name="twitter:image" content="http://something.jpg">
 
```
 
 
Do not forget about file robots.txt and sitemap.xml:
 
 
```
 
// robots.txt can contain this:
 
User-agent: *
 
Allow: /
 
 
Sitemap: http://www.example.com/sitemap.xml
 
```
 
 
```
 
// And file sitemap.xml
 
<?xml version="1.0" encoding="UTF-8"?>
 
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
 
        xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
 
  <url>
 
    <loc>http://example.com/someFile.html</loc>
 
    <image:image>
 
      <image:loc>http://example.com/someImg.jpg</image:loc>
 
    </image:image>
 
  </url> 
 
</urlset> 
 
```
 
 
You can also minify [here](https://www.willpeavy.com/tools/minifier/) or [here](http://minifycode.com/html-minifier/) all your files. Adding "microdata" can help as well, but I have never used it. On the other hand what I do is that I compress images using these two sites [tinyjpg.com](https://tinyjpg.com/) and [tinypng.com](https://tinypng.com/).
 
 
.
 
 
.
 
 
**Other useful links**
 
---
 
 
- [SVG to CSS-background-image convertor](https://websemantics.uk/tools/svg-to-background-image-conversion/)
 
 
.
 
 
.
 
 
**jQuery + draggable/droppable on mobile devices (Android)**
 
---
 
 
JQuery and its UI extension provide drag&drop functionalities, but these do not work on Android or generally on mobile devices. You can use one more dependency called [touch-punch](http://touchpunch.furf.com) to fix the problem. It should be loaded after jQuery and UI.
 
 
```
 
<!-- jQuery + UI -->
 
<script src="https://code.jquery.com/jquery-3.4.1.min.js"></script>
 
<script src="https://code.jquery.com/ui/1.12.1/jquery-ui.min.js"></script>
 
 
<!-- http://touchpunch.furf.com/ -->
 
<!-- Use this file locally -->
 
<script src="./jquery.ui.touch-punch.min.js"></script>
 
```
 
 
And then standard code should work:
 
 
```
 
<!doctype html>
 
 
<html lang="en">
 
  <head>
 
    <meta charset="utf-8">
 
 
    <title>Title</title>
 
 
    <!-- jQuery + UI -->
 
    <script src="https://code.jquery.com/jquery-3.4.1.min.js"></script>
 
    <script src="https://code.jquery.com/ui/1.12.1/jquery-ui.min.js"></script>
 
 
    <!-- http://touchpunch.furf.com/ -->
 
    <script src="./jquery.ui.touch-punch.min.js"></script>
 
 
    <style>
 
      .draggable {
 
        width: 100px;
 
        height: 100px;
 
        border: 1px solid red;
 
      }
 
 
      .droppable {
 
        width: 300px;
 
        height: 300px;
 
        border: 1px solid blue;
 
      }
 
 
      .over {
 
        background-color: gold;
 
      }
 
    </style>
 
  </head>
 
 
  <body>
 
    <div class="draggable my1">draggable my1</div>
 
    <div class="draggable my2">draggable my2</div>
 
    <div class="droppable myA">droppable myA</div>
 
    <div class="droppable myB">droppable myB</div>
 
  </body>
 
 
 
  <script>
 
    $( function() {
 
 
      // All draggables will return to their original position if not dropped to correct droppable
 
      // ... and will always stay in the area of BODY
 
      $(".draggable").draggable({ revert: "invalid", containment: "body" });
 
 
      // Demonstration of how particular droppables can accept only particular draggables
 
      $( ".droppable.myA" ).droppable({
 
        accept: ".draggable.my1",
 
        drop: function( event, ui ) {
 
 
          // positioning the dropped box into the target area
 
          var dropped = ui.draggable;
 
          var droppedOn = $(this);
 
          $(dropped).detach().css({top: 0,left: 0}).appendTo(droppedOn);    
 
          $(this).removeClass("over");
 
        },
 
        over: function(event, elem) {
 
          $(this).addClass("over");
 
          console.log("over");
 
        },
 
        out: function(event, elem) {
 
          $(this).removeClass("over");
 
        }
 
      });
 
 
      // Demonstration of how particular droppables can accept only particular draggables
 
      $( ".droppable.myB" ).droppable({
 
        accept: ".draggable.my2",
 
        drop: function( event, ui ) {
 
 
          // positioning the dropped box into the target area
 
          var dropped = ui.draggable;
 
          var droppedOn = $(this);
 
          $(dropped).detach().css({top: 0,left: 0}).appendTo(droppedOn);    
 
          $(this).removeClass("over");
 
        },
 
        over: function(event, elem) {
 
          $(this).addClass("over");
 
          console.log("over");
 
        },
 
        out: function(event, elem) {
 
          $(this).removeClass("over");
 
        }
 
      });
 
 
    });
 
  </script>
 
 
</html>
 
```
 
 
.
 
 
.
 
 
**Enhancing Gii**
 
---
 
If you do not like entering long model-paths and controller-paths in CRUD-generator, you can modify text boxes in "\vendor\yiisoft\yii2-gii\src\generators\crud\form.php" and enter default paths and then only manually add the name of the model.
 
 
```php
 
if (!$generator->modelClass) {
 
echo $form->field($generator, 'modelClass')->textInput(['value' => 'app\\models\\']);
 
echo $form->field($generator, 'searchModelClass')->textInput(['value' => 'app\\models\\*Search']);
 
echo $form->field($generator, 'controllerClass')->textInput(['value' => 'app\\controllers\\*Controller']);
 
} else {
 
echo $form->field($generator, 'modelClass');
 
echo $form->field($generator, 'searchModelClass');
 
echo $form->field($generator, 'controllerClass');
 
}
 
```
 
 
.
 
 
.
 
 
**Webproject outsite docroot (htdocs) folder (Windows)**
 
---
 
If you need to store you project for example in folder D:\GIT\EmployerNr1\ProjectNr2, you can. Just modify 2 files and restart Apache (I am using XAMPP under Win):
 
 
- C:\Windows\System32\drivers\etc\hosts
 
 
```
 
127.0.0.1 myFictiveUrl.local
 
```
 
 
- C:\xampp\apache\conf\extra\httpd-vhosts.conf
 
 
```
 
<VirtualHost *:80>
 
  DocumentRoot "D:\GIT\EmployerNr1\ProjectNr2"
 
  ServerName myFictiveUrl.local
 
  ServerAlias myFictiveUrl.local
 
  <Directory "D:\GIT\EmployerNr1\ProjectNr2">
 
    Options Indexes FollowSymLinks
 
    AllowOverride All
 
    Order allow,deny
 
    Allow from all
 
    # New directive needed in Apache 2.4.3:
 
    Require all granted
 
  </Directory>
 
</VirtualHost>
 
```
 
 
You can then use http://myFictiveUrl.local in your browser
 
 
.
 
 
.
 
 
**Modal window + ajax**
 
---
 
Let's have a GridView (list of users) with edit-button which will open the edit-form in a modal window. Once user-detail is changed, ajax validation will be executed. If something is wrong, the field will be highlighted. If everything is OK and saved, modal window will be closed and the GridView will be updated.
 
 
Let's add the button to the GridView in the view **index.php** and let's wrap the GridView into the Pjax. Also ID is added to the GridView so it can be refreshed later via JS:
 
 
```php
 
<?php yii\widgets\Pjax::begin();?>
 
<?= GridView::widget([
 
  'dataProvider' => $dataProvider,
 
  'filterModel' => $searchModel,
 
  'id' => 'user-list-GridView',
 
  'columns' => [
 
    ['class' => 'yii\grid\SerialColumn'],
 
      'id',
 
      'username',
 
      'email:email',
 
      ['class' => 'yii\grid\ActionColumn',
 
        'buttons' => [
 
          'user_ajax_update_btn' => function ($url, $model, $key) {
 
            return Html::a ( '<span class="glyphicon glyphicon-share"></span> ', 
 
  ['user/update', 'id' =>  $model->id], 
 
  ['class' => 'openInMyModal', 'onclick'=>'return false;', 'data-myModalTitle'=>'']
 
    );
 
          },
 
        ],
 
        'template' => '{update} {view} {delete} {user_ajax_update_btn}'
 
      ],
 
  ],
 
]); ?>
 
<?php yii\widgets\Pjax::end();?>
 
```
 
 
 
Plus add (to the end of this view) following JS code:
 
 
```php
 
<?php
 
// This section can be moved to "\views\layouts\main.php"
 
yii\bootstrap\Modal::begin([
 
  'header' => '<span id="myModalTitle">Title</span>',
 
  'id' => 'myModalDialog',
 
  'size' => 'modal-lg',
 
  'clientOptions' => [
 
      // https://getbootstrap.com/docs/3.3/javascript/#modals-options
 
      'keyboard' => false, // ESC key won't close the modal
 
      'backdrop' => 'static', // clicking outside the modal will not close it
 
      ],
 
]);
 
echo "<div id='myModalContent'></div>";
 
yii\bootstrap\Modal::end();
 
 
$this->registerJs(
 
 "// If you use $(document).on, it will handle also events on elements rendered by AJAX.
 
   $(document).on('click','a.openInMyModal',function(e){  
 
  // And if you use $('a.openInMyModal'), it will work only on standard elements
 
  // $('a.openInMyModal').click(function(e){  
 
  
 
  // Prevents the browsers default behaviour (such as opening a link)
 
  // ... but does not stop the event from bubbling up the DOM
 
  e.preventDefault(); 
 
  
 
  // Prevents the event from bubbling up the DOM
 
  // ... but does not stop the browsers default behaviour
 
  // e.stopPropagation(); 
 
  
 
  // Prevents other listeners of the same event from being called
 
  // e.stopImmediatePropagation(); 
 
  
 
  // Good idea is to set onclick='return false;' to the link if it is in a modal window
 
  
 
  let title = $(this).attr('data-myModalTitle');
 
  if (title==undefined) { title = ''; }
 
  
 
  $('#myModalDialog #myModalTitle').text(title);
 
  $('#myModalDialog').find('#myModalContent').html('');
 
  $('#myModalDialog').modal('show')
 
    .find('#myModalContent')
 
    .load($(this).attr('href'));
 
  return false;
 
  });",
 
  yii\web\View::POS_READY,
 
  'myModalHandler'
 
);
 
?>
 
```
 
 
Now we need to modify the **updateAction**:
 
 
```php
 
public function actionUpdate($id)
 
{
 
  $model = $this->findModel($id);
 
 
  if ($model->load(Yii::$app->request->post()) && $model->save()) {
 
    if (Yii::$app->request->isAjax) {
 
      return "<script>"
 
        . "$.pjax.reload({container:'#user-list-GridView'});"
 
        . "$('#myModalDialog').modal('hide');"
 
        . "</script>";
 
    }
 
 
    return $this->redirect(['view', 'id' => $model->id]);
 
  }
 
 
  if (Yii::$app->request->isAjax) {
 
    return $this->renderAjax('update', [
 
      'model' => $model,
 
    ]);
 
  }
 
    
 
  return $this->render('update', [
 
        'model' => $model,
 
  ]);
 
}
 
```
 
 
 
And file \_form.php:
 
 
```php
 
<?php yii\widgets\Pjax::begin([
 
  'id' => 'user-detail-Pjax', 
 
  'enablePushState' => false, 
 
  'enableReplaceState' => false
 
]);  ?>
 
 
<?php $form = ActiveForm::begin([
 
  'id'=>'user-detail-ActiveForm',
 
  'options' => ['data-pjax' => 1 ]
 
  ]); ?>
 
 
<?= $form->field($model, 'username')->textInput(['maxlength' => true]) ?>
 
 
<?= $form->field($model, 'password')->passwordInput(['maxlength' => true]) ?>
 
 
<?= $form->field($model, 'email')->textInput(['maxlength' => true]) ?>
 
 
<?= $form->field($model, 'authKey')->textInput(['maxlength' => true]) ?>
 
 
<div class="form-group">
 
    <?= Html::submitButton(Yii::t('app', 'Save'), ['class' => 'btn btn-success']) ?>
 
</div>
 
 
<?php ActiveForm::end(); ?>
 
 
<?php yii\widgets\Pjax::end() ?>
 
```
 
 
**Simple Bootstrap themes**
 
---
 
There is this page [bootswatch.com](https://bootswatch.com) which provides simple bootstrap themes. It is enough to replace one CSS file - you can do it in file "views/layouts/main.php" just by adding following row before < /head > tag:
 
 
```html
 
<link href="https://bootswatch.com/3/united/bootstrap.min.css" rel="stylesheet">
 
 
</head>
 
```
 
 
Note that currently Yii2 is using Bootstrap3 so when searching for themes, dont forget to switch to section [Bootstrap 3](https://bootswatch.com/3/).
 
 
Important: Yii2 is using navbar with classes "navbar-inverse navbar-fixed-top". If you are using themes from Bootswatch, change the navbar class to "navbar navbar-default navbar-fixed-top" otherwise the top menu-bar will have weird color. This is also done in file "views/layouts/main.php" like this:
 
 
```php
 
    NavBar::begin([
 
        // ...
 
        'options' => [
 
            'class' => 'navbar navbar-default navbar-fixed-top',
 
        ],
 
    ]);
 
```
 
 
Note: If you want to download the theme, you should link it like this:
 
 
```html
 
<link href="<?=Yii::$app->getUrlManager()->getBaseUrl()?>/css/bootstrap-bootswatch-united.min.css" rel="stylesheet">
 
```
 
 
Now you technically do not need the original bootstrap.css file so you can remove it in "basic/config/web.php" by adding the assetManager section to "components":
 
 
```php
 
'components' => [
 
  // https://stackoverflow.com/questions/26734385/yii2-disable-bootstrap-js-jquery-and-css
 
  'assetManager' => [
 
    'bundles' => [
 
'yii\bootstrap\BootstrapAsset' => [
 
  'css' => [],
 
 ],
 
     ],
 
   ],
 
```
 
 
**Yii2 + Composer**
 
---
 
Once composer is installed, you might want to use it to download Yii, but following command might not work:
 
 
```
 
php composer.phar create-project yiisoft/yii2-app-basic basic
 
```
 
 
Change it to:
 
 
```
 
composer create-project yiisoft/yii2-app-basic basic
 
```
 
 
.. and run it. If you are in the desired folder right now, you can use . (dot) instead of the last "word":
 
 
```
 
composer create-project yiisoft/yii2-app-basic .
 
```
 
 
**Using DatePicker**
 
 
Run this command:
 
 
```
 
composer require --prefer-dist yiisoft/yii2-jui
 
```
 
 
and then use this code in your view:
 
 
 
```php
 
<?= $form->field($model, 'date_deadline')->widget(\yii\jui\DatePicker::classname(), [
 
    //'language' => 'en',
 
    'dateFormat' => 'yyyy-MM-dd',
 
    'options' => ['class' => 'form-control']
 
]) ?>
 
```
 
 
Read more at [the official documentation](https://www.yiiframework.com/extension/yiisoft/yii2-jui) and on [GIT](https://github.com/yiisoft/yii2-jui)
 
 
**Favicon**
 
---
 
Favicon is already included, but it nos used in the basic project. Just type this into views/layouts/main.php:
 
 
```html
 
<link rel="icon" type="image/png" sizes="16x16" href="favicon.ico">
 
```
 
 
Or you can use the official yii-favicon:
 
 
```html
 
<link rel="apple-touch-icon" sizes="180x180" href="https://www.yiiframework.com/favico/apple-touch-icon.png">
 
<link rel="icon" type="image/png" sizes="32x32" href="https://www.yiiframework.com/favico/favicon-32x32.png">
 
<link rel="icon" type="image/png" sizes="16x16" href="https://www.yiiframework.com/favico/favicon-16x16.png">
 
```
 
 
**GridView + DatePicker in filter + filter reset**
 
---
 
If you are using DatePicker as described above, you can use it also in GridView as a filter, but it will not work properly. Current filter-value will not be visible and resetting the filter wont be possible. Use following in views/xxx/index.php to solve the issue:
 
 
```php
 
function getDatepickerFilter($searchModel, $attribute) {
 
  $name = basename(get_class($searchModel)) . "[$attribute]";
 
  $result = \yii\jui\DatePicker::widget(['language' => 'en', 'dateFormat' => 'php:Y-m-d', 'name'=>$name, 'value'=>$searchModel->$attribute, 'options' => ['class' => 'form-control'] ]);
 
  if (trim($searchModel->$attribute)!=='') {
 
    $result = '<div style="display:flex;flex-direction:column">' . $result
 
    . '<div class="btn btn-danger btn-xs glyphicon glyphicon-remove" onclick="$(this).prev(\'input\').val(\'\').trigger(\'change\')"></div></div>';
 
  }
 
  return $result;
 
}
 
 
// ...
 
 
<?= GridView::widget([
 
  'dataProvider' => $dataProvider,
 
  'filterModel' => $searchModel,
 
  'columns' => [
 
  // ...
 
  [
 
    'attribute' => 'myDateCol',
 
    'value' => 'myDateCol',
 
    'label'=>'My date label',
 
    'filter' => getDatepickerFilter($searchModel,'myDateCol'),
 
    'format' => 'html'
 
  ],
 
        
 
  // ...
 
        
 
```
 
 
**Drop down list for foreign-key column**
 
--
 
 
Do you need to specify for example currency using a predefined list, but your view contains only a simple text-input where you must manually enter currency_id from table Currency? 
 
 
Read how to enhance it. 
 
 
```php
 
use yii\helpers\ArrayHelper;
 
use app\models\Currency; // My example uses Currency model
 
 
$currencies = Currency::find()->asArray()->all();
 
 
// 'id' = the primary key column
 
// 'name' = the column with text to be dispalyed to user
 
// https://www.yiiframework.com/doc/api/2.0/yii-helpers-basearrayhelper#map()-detail
 
$currencies = ArrayHelper::map($currencies, 'id', 'name'); 
 
 
<?= $form->field($model, 'id_currency')->dropDownList($currencies) ?>
 
```
 
 
Note: In other views you will need models with predefined relations to reach the correct value. Relations can be created using GII (when they are defined in DB) or [manually](https://www.yiiframework.com/doc/guide/2.0/en/db-active-record#relational-data).
 
 
**GridView - Variable page size**
 
---
 
GridView cannot display DropDownList which could be used by the user to change the number of rows per page. You have to add it manually like this:
 
 
When you are creating a new model using Gii, you can select if you want to create the SearchModel as well. Do it, it is usefull for example in this situation. Then add following rows to the model:
 
 
```php
 
// file models/InvoiceSearch.php
 
 
use yii\helpers\Html; // add this row
 
 
class InvoiceSearch extends Invoice
 
{
 
  public $pageSize = null // add this row
 
  // ...
 
  
 
  // This method already exists:
 
  public function rules()
 
  {
 
    return [ // ...
 
      ['pageSize', 'safe'], // add this row
 
      // ...
 
  
 
  // Add this function:
 
  public function getPageSizeDropDown($htmlOptions = [], $prefixHtml = '', $suffixHtml = '', $labelPrefix = '') {
 
    return $prefixHtml . Html::activeDropDownList($this, 'pageSize',
 
      [
 
        10 => $labelPrefix.'10', 
 
        20 => $labelPrefix.'20', 
 
        50 => $labelPrefix.'50', 
 
        100 => $labelPrefix.'100', 
 
        150 => $labelPrefix.'150', 
 
        200 => $labelPrefix.'200', 
 
        300 => $labelPrefix.'300', 
 
        500 => $labelPrefix.'500', 
 
        1000 => $labelPrefix.'1000'
 
      ],$htmlOptions ) . $suffixHtml;
 
    }
 
 
    // Add this function:
 
    public function getPageSizeDropDownID($prefix = '#') {
 
      return $prefix . Html::getInputId($this, 'pageSize');
 
    }
 
    
 
    // This method already exists:
 
    public function search($params)
 
    {
 
        // Remember to call load() first and then you can work with pageSize
 
        $this->load($params);
 
        
 
        // Add following rows:
 
        if (!isset($this->pageSize)) {
 
          // Here we make sure that the dropDownLst will have correct value preselected
 
          $this->pageSize = $dataProvider->pagination->defaultPageSize;
 
        } 
 
        $dataProvider->pagination->pageSize = (int)$this->pageSize; 
 
        
 
```
 
 
And then in your views/xxx/index.php use following:
 
 
```php
 
$pageSizeDropDown = $searchModel->getPageSizeDropDown(['class' => 'form-control', 'style'=>'width: 20rem'],'','','Rows per page: ');
 
 
echo GridView::widget([
 
  'dataProvider' => $dataProvider,
 
  'filterModel' => $searchModel,
 
  'layout'=>'{summary}<br>{items}<br><div style="display:flex; background-color: #f9f9f9; padding: 0px 3rem;"><div style="flex-grow: 2;">{pager}</div><div style="align-self:center;">'.$pageSizeDropDown.'</div></div>',
 
  'pager' => [ 'maxButtonCount' => 20 ],
 
  
 
  'filterSelector' => $searchModel->getPageSizeDropDownID(),
 
  // filterSelector is the core solution of this problem. It refreshes the grid.
 
```
 
 
**Creating your new helper class**
 
---
 
Sometimes you need a static class that will do things for you. This is what helpers do.
 
 
I work with the Basic example so I do things like this:
 
- Create folder named "myHelpers" next to the folder "controllers"
 
- Place there your class and do not forget about the "namespace":
 
 
```php
 
<?php
 
namespace myHelpers;
 
class MyClassName { /* ... */ }
 
```
 
 
- Now open file index.php and add following row:
 
 
```php
 
require __DIR__ . '/../myHelpers/MyClassName.php';
 
```
 
 
- If you want to use the class, do not forget to include the file:
 
 
```php
 
use myHelpers\MyClassName;
 
// ...
 
echo MyClassName::myMethod(123);
 
```
 
 
**Form-grid renderer**
 
---
 
If you want your form to be rendered in a grid, you can use your custom new helper to help you. How to create helpers is mentioned right above. The helper then looks like this:
 
 
```php
 
<?php
 
namespace myHelpers;
 
 
class GridFormRenderer {
 
  
 
  // https://www.w3schools.com/bootstrap/bootstrap_grid_system.asp
 
  // Bootstrap works with 12-column layouts so max span is 12.
 
  public static function renderGridForm($gridForm, $colSize = 'md', $nullReplacement = '&nbsp;', $maxBoootstrapColSpan = 12) {
 
    $result = '';
 
    foreach ($gridForm as $row) {
 
      if (is_null($row)) {
 
        $colSpan = $maxBoootstrapColSpan;
 
        $result .= '<div class="row">' . '<div class="col-' . $colSize . '-' . $colSpan . '">' . $nullReplacement . '</div></div>';
 
        continue;
 
      }
 
      $colSpan = floor($maxBoootstrapColSpan / count($row));
 
      $result .= '<div class="row">';
 
      foreach ($row as $col) {
 
        if (is_null($col)) {
 
          $col = $nullReplacement;
 
        }
 
        $result .= '<div class="col-' . $colSize . '-' . $colSpan . '">' . $col . '</div>';
 
      }
 
      $result .= '</div>';
 
    }
 
    return $result;
 
  }
 
}
 
 
```
 
 
And is used like this in any view:
 
 
```php
 
use myHelpers\GridFormRenderer;
 
// ...
 
 
$form = ActiveForm::begin();
 
 
$username = $form->field($model, 'username')->textInput(['maxlength' => true]);
 
$password_new = $form->field($model, 'password_new')->passwordInput(['maxlength' => true]);
 
$password_new_repeat = $form->field($model, 'password_new_repeat')->passwordInput(['maxlength' => true]);
 
$email = $form->field($model, 'email')->textInput(['maxlength' => true]);
 
 
$gridForm = [
 
  [$username, null, $email], // null = empty cell
 
  null, // null = empty row
 
  [$password_new, $password_new_repeat],
 
  ];
 
 
echo GridFormRenderer::renderGridForm($gridForm);
 
 
ActiveForm::end();
 
// ...
 
 
```
 
 
The result is that your form has 3 rows, the middle one is empty. In the first row there are 3 cells (username, nothing, email) and in the last row there  is 2x password.
 
 
You do not have to write any HTML, you only arrange inputs into any number of rows and columns (using the array $gridForm) and things just happen automagically.
 
 
**Netbeans + Xdebug**
 
---
 
Note: I am using Windows 10 + XAMPP
 
 
I had to follow 2 manuals:
 
- [https://www.codewall.co.uk/debug-php-with-xdebug-on-netbeans](https://www.codewall.co.uk/debug-php-with-xdebug-on-netbeans/)
 
- [https://stackoverflow.com/questions/2963027/netbeans-xdebug-php-not-working](https://stackoverflow.com/questions/2963027/netbeans-xdebug-php-not-working)
 
 
The result in C:\xampp\php\php.ini was:
 
 
```
 
[XDebug]
 
zend_extension = c:\xampp\php\ext\php_xdebug.dll
 
xdebug.remote_enable = on
 
xdebug.idekey = netbeans-xdebug
 
xdebug.remote_host = localhost
 
xdebug.remote_port = 9000
 
xdebug.remote_autostart=on
 
xdebug.var_display_max_depth=5
 
```
 
 
The last row changes behaviour of var_dump() when xdebug is installed. It does not output whole arrays, but max 3 levels. Read [here](https://www.php.net/manual/es/function.var-dump.php) or [here](https://xdebug.org/docs/display).
 
 
Quotes were not important. I didnt even need to [download](https://xdebug.org/download) current version of xdebug, it was already in folder C:\xampp\php\ext.
 
 
Important also is to righ-click your project, select Properties, then menu "Run configurations" and set correct path to your index.php. Best is to use the button "Browse"
 
 
Then you just add a breakpoint, click the debug-play button in NetBeans and refresh your browser. Netbeans will stop the code for you.
 
 
**PDF - no UTF, only English chars - FPDF**
 
---
 
For creating PDFs can be used [FPDF](http://www.fpdf.org) library. It is extremely simple to make it run. Just download it and then use it as a helper - I described how this is done [above](https://www.yiiframework.com/wiki/2552/yii-v2-snippet-guide#creating-your-new-helper-class). Do not forget to add namespace to FPDF.php.
 
 
You will only need **FPDF.php** and folder **font**. Then in your controller just do this:
 
 
```php
 
use myHelpers\FPDF;
 
// ...
 
$pdf = new FPDF();
 
$pdf->AddPage();
 
$pdf->SetFont('Arial','B',16);
 
$pdf->Cell(40,10,'Hello World!');
 
$pdf->Output('D', 'hello.pdf');
 
```
 
 
Note: I renamed original file fpdf.php to FPDF.php
 
 
The only disadvantage is that UTF cannot be used and conversion to older encodings is required. For Czech Republic all texts must be converted like this:
 
 
```php
 
private function convertUtf8ToWin1250($value) {
 
  $value = trim($value);
 
  if (strlen($value)==0) {
 
    // Warning:
 
    // Method strlen() returns number of bytes, not necessiraly number of characters.
 
    // Usually it is the same, but not always.
 
    // see also mb_strlen()
 
    return '';
 
  }
 
  return iconv("UTF-8", "WINDOWS-1250//IGNORE", $value );
 
}
 
```
 
 
A discussion is available [here](https://stackoverflow.com/questions/6334134/fpdf-utf-8-encoding-how-to).
 
 
**PDF - UTF, all chars - tFPDF**
 
---
 
 
When you need non-English characters, [tFPDF](http://www.fpdf.org/en/script/script92.php) should be used. It is the same as FPDF so FPDF documentation and manual can be used. It only modifies character-handling. 
 
 
Just download it and then use it as a helper - I described how this is done [above](https://www.yiiframework.com/wiki/2552/yii-v2-snippet-guide#creating-your-new-helper-class).
 
 
Summary: 
 
- Download tFPDF and unpack it.
 
- use file **tfpdf.php** and folder **font** .. it contains file **ttfonts.php** !!
 
- Into both mentioned php files add the namespace you are using for your helpers. 
 
- Do other modifications needed to use it as a Helper. Explained above.
 
 
tFPDF example:
 
 
```php
 
$pdf = new tFPDF();
 
 
$pdf->AddFont('DejaVu','','DejaVuSansCondensed.ttf',true);
 
$pdf->AddFont('DejaVu','B','DejaVuSansCondensed-Bold.ttf',true);
 
$pdf->SetFont('DejaVu','',10);
 
 
$pageWidth = 210;
 
$pageMargin = 10;
 
$maxContentW = $pageWidth - 2*$pageMargin; // = max width of an element
 
 
$pdf->SetAutoPageBreak(true, 0);
 
$pdf->SetMargins($pageMargin, $pageMargin, $pageMargin);
 
$pdf->SetAutoPageBreak(true, $pageMargin);
 
 
// Settings for tables:
 
$pdf->SetLineWidth(0.2);
 
$pdf->SetDrawColor(0, 0, 0);
 
 
$pdf->AddPage();
 
/ $pdf->SetFontSize(8);
 
 
$displayBorders = 1;
 
$valueAlign = "L";
 
$labelAlign = "L";
 
 
$usedHeight = 0;
 
 
// Logo on the 1st line
 
$pdf->SetY($pageMargin);
 
$pdf->SetX($pageMargin);
 
$logoPath = '../tesla.png';
 
$imgWidth = 10;
 
$headerHeight = 10;
 
$pdf->Image($logoPath, null, null, $imgWidth, $headerHeight);
 
 
$pdf->SetY($pageMargin);
 
$pdf->SetX($pageMargin+$imgWidth);
 
$pdf->Cell($maxContentW-$imgWidth, $headerHeight, 'Non English chars: ěščřžýáíéúů', $displayBorders, 0, 'C', false);
 
 
$usedHeight+= $headerHeight;
 
$usedHeight+=10;
 
        
 
$pdf->SetY($pageMargin);
 
$pdf->SetX($pageMargin+$imgWidth);
 
$pdf->Cell($maxContentW-$imgWidth, 10, 'Non English chars: ěščřžýáíéúů', $displayBorders, 0, 'C', false);
 
 
$pdf->SetY($pageMargin + $usedHeight);
 
$pdf->SetX($pageMargin);
 
$pdf->Cell(30, 10, 'Customer number:', $displayBorders, 0, 'R', false);
 
 
$pdf->SetFont('DejaVu','B',14);
 
 
$pdf->SetY($pageMargin + $usedHeight);
 
$pdf->SetX($pageMargin + 30);
 
$pdf->Cell(20, 10, 'ABC123', $displayBorders, 0, 'L', false);
 
 
$pdf->Output('D', 'hello.pdf');
 
```
 
 
Note to tFPFD: Once you use it, it creates a few PHP and DAT files in folder **unifont**. Delete them before uploading to the internet. They will contain hardcoded paths to fonts and must be recreated.
 
 
**PDF - 1D & 2D Barcodes - TCPDF**
 
---
 
See part II of this guide:
 
- [https://www.yiiframework.com/wiki/2558/yii-v2-snippet-guide-ii](https://www.yiiframework.com/wiki/2558/yii-v2-snippet-guide-ii)
 
 
**Export (not only GridView) to CSV in UTF-8 without extensions **
 
---
 
I will describe how to easily export GridView into CSV so that filers and sorting is kept. I do not use any extentions which are so famous today.
 
Note that GridView is not needed, I just want to show the most complicated situation.
 
 
Let's say you have page on URL user/index and it contains GridView where you can list and filter users.
 
 
> Note: In class yii\data\Sort, in method getAttributeOrders(), is the sorting parameter taken from Yii::$app->getRequest() so the name of the sorted column must be in the URL you are using at the moment. This is why sorting might not work if you want to run UserSearch->search() manually without any GET parameters available in Yii::$app->request->queryParams.
 
 
The basic method for exporting DataProvider is here:
 
 
```php
 
public function exportDataProviderToCsv($dataProvider) {
 
 
  // Setting infinite number of rows per page to receive all pages at once
 
  $dataProvider->pagination->pageSize = -1;
 
 
  // All text-rows will be placed in this array. 
 
  // We will later use implode() to insert separators and join everything into 1 large string
 
  $rows = [];
 
 
  // UTF-8 header = chr(0xEF) . chr(0xBB) . chr(0xBF)
 
  // Plus column names in format: 
 
  // ID;Username;Email etc based on your column names
 
  $rows [] = chr(0xEF) . chr(0xBB) . chr(0xBF) . User::getCsvHeader();
 
 
  foreach ($dataProvider->models as $m) {
 
    // Method getCsvRow() returns CSV row with values. Example:
 
    // 1;petergreen;peter.green@gmail.com ...
 
    $row = trim($m->getCsvRow());
 
    if ($row!='') {
 
      $rows[] = $row;  
 
    }
 
  }
 
 
  // Here we use implode("\n",$rows) to create large string with rows separated by new lines. 
 
  // Double quotes must be used around \n !
 
  $csv = implode("\n", $rows);
 
 
  $currentDate = date('Y-m-d_H-i-s');
 
 
  return \Yii::$app->response->sendContentAsFile($csv, 'users_' . $currentDate . '.csv', [
 
    'mimeType' => 'application/csv',
 
    'inline' => false
 
  ]);
 
}
 
```
 
 
If you want to use it to export data from your GridView, modify your action like this:
 
 
```php
 
public function actionIndex($exportToCsv=false) {
 
 
  // These 2 rows already existed
 
  $searchModel = new UserSearch();
 
  $dataProvider = $searchModel->search(Yii::$app->request->queryParams)
 
        
 
  if ($exportToCsv) {
 
    $this->exportDataProviderToCsv($dataProvider);  
 
    return;       
 
  }
 
  // ...
 
}
 
```
 
 
And right above your GridView place this link:
 
 
```html
 
<?php 
 
  // Pjax::begin(); // If you are using Pjax for GridView, it must start before following buttons.
 
?>
 
 
<div style="display:flex;flex-direction:row;">
 
  <?= Html::a('+ Create new record', ['create'], ['class' => 'btn btn-success']) ?>
 
  &nbsp;
 
  <div class="btn-group">
 
    <button type="button" class="btn btn-info dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
 
      Export to CSV&nbsp;<span class="caret"></span>
 
    </button>
 
    <ul class="dropdown-menu">
 
      <li><?php
 
          echo Html::a('Ignore filters and sorting', ['index', 'exportToCsv' => 1], ['target' => '_blank', 'class' => 'text-left', 'data-pjax'=>'0']);
 
          // 'data-pjax'=>'0' is necessaary to avoid PJAX. 
 
          // Now we need to open the link in a new tab, not to resolve it as an ajax request.
 
          ?></li>
 
      <li><?php
 
          $csvUrl = \yii\helpers\Url::current(['exportToCsv' => 1]);
 
          echo Html::a('Preserve filters and sorting', $csvUrl, ['target' => '_blank', 'class' => 'text-left', 'data-pjax'=>'0']);
 
          // 'data-pjax'=>'0' is necessaary to avoid PJAX. 
 
          // Now we need to open the link in a new tab, not to resolve it as an ajax request.
 
          ?></li>
 
    </ul>
 
  </div>
 
</div>
 
 
<php
 
// Here goes the rest ... 
 
// echo GridView::widget([
 
// ...
 
?>
 
```
 
 
In my code above there were used 2 methods in the model which export things to the CSV format. My implementatino is here:
 
 
```php
 
public static function getCsvHeader() {
 
  $result = [];
 
  $result[] = "ID";
 
  $result[] = "Username";
 
  $result[] = "Email";
 
  // ...
 
  return implode(";", $result);
 
}
 
public function getCsvRow() {
 
  $result = [];
 
  $result[] = $this->id;
 
  $result[] = $this->username;
 
  $result[] = $this->email;
 
  // ...
 
  return implode(";", $result);
 
}
 
 
```
 
 
**Next chapters had to be moved to a new article!**
 
---
7 0
4 followers
Viewed: 275 210 times
Version: 2.0
Category: Tutorials
Written by: rackycz
Last updated by: rackycz
Created on: Sep 19, 2019
Last updated: a year ago
Update Article

Revisions

View all history