Yii2: Tips & Tricks

Sometimes I find something cool in Yii (probably not documented yet) and I’d like to share it with you.

So I’ll paste some code in this post to show what’s hiding under the hood.

Feel free to comment, criticize or propose your own solutions

[sub](also plz excuse my english and feel free to correct me).[/sub]

1 Like

Ok, let’s start.

As you already know, in Yii2 you need to return() the result from the action:


return $this->render('index', [

    'items' => $items,

    'sort' => $sort,

    'pages' => $pages,

]);

This doesn’t make much sense, until you do something like this:


$items = ['some', 'array', 'of', 'values' => ['associative', 'array']];

\Yii::$app->response->format = 'json';

return $items;



And - voila! - you have

JSON response right out of the box:


{"0":"some","1":"array","2":"of","values":["associative","array"]}

Ok, seems cool, but what about creating arrays of data?

It’s pretty simple too:


$items = \app\models\MyModel::find()->asArray()->all();

Result (json is off):




Array

(

    [id] => 532b436477936

    [created_at] => 1395344236

    [updated_at] => 1395344236

    [weight] => 0

    [is_disabled] => 0

    [region_id] => 532b3fcbf2353

    [slug] => 

    [name] => Record #1

    [description] => 

    [lng] => 37.403118

    [lat] => 55.803691

)

Array

(

    [id] => 532b436f10ea8

    [created_at] => 1395344245

    [updated_at] => 1395344245

    [weight] => 0

    [is_disabled] => 0

    [region_id] => 52901b3f81daa

    [slug] => 

    [name] => Record #2

    [description] => 

    [lng] => 37.463772

    [lat] => 55.808827

)



What asArray() does is telling query builder to forget about ActiveRecord stuff and return plain array from DB.

Using asArray() can improve application performance, it also saves memory a lot.

And yes,

relations are also available:


$items = \app\models\MyModel::find()

    ->with(['region'])

    ->asArray()

    ->all();

Result:


Array

(

    [id] => 532b436477936

    [created_at] => 1395344236

    [updated_at] => 1395344236

    [weight] => 0

    [is_disabled] => 0

    [region_id] => 532b3fcbf2353

    [slug] => 

    [name] => Record #1

    [description] => 

    [lng] => 37.403118

    [lat] => 55.803691

    [region] => Array

        (

            [id] => 532b3fcbf2353

            [created_at] => 1395343322

            [updated_at] => 1395343486

            [weight] => 10

            [is_disabled] => 0

            [slug] => moskva

            [name] => Region #2

            [description] => 

            [lng] => 37.619899

            [lat] => 55.753676

        )

)

Array

(

    [id] => 532b436f10ea8

    [created_at] => 1395344245

    [updated_at] => 1395344245

    [weight] => 0

    [is_disabled] => 0

    [region_id] => 52901b3f81daa

    [slug] => 

    [name] => Record #2

    [description] => 

    [lng] => 37.463772

    [lat] => 55.808827

    [region] => Array

        (

            [id] => 52901b3f81daa

            [created_at] => 1385175881

            [updated_at] => 1395007960

            [weight] => 10

            [is_disabled] => 0

            [slug] => ekb

            [name] => Region #1

            [description] => 

            [lng] => 56.84147874956556

            [lat] => 60.606141082031165

        )


)

What about selecting only a few columns? easily:


$items = \app\models\MyModel::find()

    ->with(['region' => function($q) {

        $q->select(['id', 'name', 'slug']);

    }])

    ->select(['id', 'name', 'region_id'])

    ->asArray()

    ->all();

Result:




Array

(

    [id] => 532b436477936

    [name] => Record #1

    [region_id] => 532b3fcbf2353

    [region] => Array

        (

            [id] => 532b3fcbf2353

            [name] => Region #2

            [slug] => moskva

        )


)

Array

(

    [id] => 532b436f10ea8

    [name] => Record #2

    [region_id] => 52901b3f81daa

    [region] => Array

        (

            [id] => 52901b3f81daa

            [name] => Region #1

            [slug] => ekb

        )


)



Cool, huh?

Nice examples.

BTW, Yii offers another way to do the below




$items = ['some', 'array', 'of', 'values'];

\Yii::$app->response->format = 'json';

return $items;



You can also maybe do this:




$items = ['some', 'array', 'of', 'values'];

return \yii\helpers\Json::encode($items);



I understand, unless you use the render method, the layout will not be used normally.

Bad thing is, we can have some complicated logic inside our models.

For example, our own getters:


public function getCreated()

{

    return $this->created_at ? date('d-m-Y', $this->created_at) : null;

}

(okay, that was not complicated, just proving my point)

If only we could convert our models to arrays… Wait! Turns out, we already can do that!

Here’s how it’s done:


$item = \app\models\Region::find($id);

$item->toArray();



Result:


Array

(

    [id] => 52901b3f81daa

    [created_at] => 1385175881

    [updated_at] => 1395007960

    [weight] => 10

    [is_disabled] => 0

    [slug] => ekb

    [name] => Region #1

    [description] => 

    [lng] => 56.84147874956556

    [lat] => 60.606141082031165

)

We can also provide a couple of args to this function to limit number of columns:


$item->toArray(['id', 'name']);

Result:


Array

(

    [id] => 52901b3f81daa

    [name] => Region #1

)



Notice that if we add our getter to this list, the result does not change (and no notice will be raised):


$item->toArray(['id', 'name', 'created']);

Result:


Array

(

    [id] => 52901b3f81daa

    [name] => Region #1

)

It’s by design, see this topic.

Anyway, to use our custom fields we need to list this field inside fields() function in our model:


public function fields()

{

    return ['id', 'name', 'created'];

}



Now everything is ok:


Array

(

    [id] => 52901b3f81daa

    [name] => Region #1

    [created] => 23-11-2013

)

We can also grab related models, by listing them in fields():


public function fields()

{

    return ['id', 'name', 'created', 'region'];

}


Array

(

    [id] => 532b436477936

    [name] => Record #1

    [created] => 20-03-2014

    [region] => Array

        (

            [id] => 532b3fcbf2353

            [name] => Region #2

            [created_at] => 1395343322

        )

)

Or even better (I suppose this is the way it should be done):

we define our custom fields and relations in extraFields()"


public function extraFields()

{

    return ['created', 'region'];

}



and then mention them as second argument of toArray():


$item->toArray(

    ['id', 'name'], // here are our table fields

    ['created', 'region'] // and here are our custom logic

);



Notice that $item->toArray() without arguments will use defined fields() function or parent implementation of it.

Yes, and probably it would be better, because of JSON_UNESCAPED_UNICODE flag (php >= 5.4).

All those \u0435\u0442\u0435\u0440 make me sad.

Sometimes we may want to avoid too much RBAC, and make our auth system pure role-based.

That is, something like this:


public function behaviors()

{

    return [

        'access' => [

            'class' => 'yii\web\AccessControl',

            'rules' => [

                [

                    'allow' => true,

                    'roles' => ['moderator', 'admin'], // only these roles are allowed to access

                ],

            ],

        ],

    ];

}

But Yii2 has only two roles by default: ? and @.

Here’s how it can be done.

First, we override AccessRule class:


<?php


namespace app\components;


class AccessRule extends \yii\web\AccessRule

{

    protected function matchRole($user)

    {

        if (empty($this->roles)) {

            return true;

        }

        foreach ($this->roles as $role) {

            if ($role === '?' && $user->getIsGuest()) {

                return true;

            } elseif ($role === '@' && !$user->getIsGuest()) {

                return true;

            } elseif (!$user->getIsGuest()) {

                // user is not guest, let's check his role (or do something else)

                if ($role === $user->identity->role) {

                    return true;

                }

            }

        }

        return false;

    }

}

Next, we inject this class into access configuration:


public function behaviors()

{

    return [

        'access' => [

            'class' => 'yii\web\AccessControl',

            'ruleConfig' => [

                'class' => 'app\components\AccessRule' // <==== HERE IT IS!

            ],

            'rules' => [

                [

                    'allow' => true,

                    'roles' => ['moderator', 'admin'],

                ],

            ],

        ],

    ];

}

And we’re done here.

(thx Qiang for the tip)

The most frequent question is "how to deal with HABTM?".

Actually, it’s not hard at all.

First of all, we create our relation (example from docs):


class Order extends \yii\db\ActiveRecord

{

    public function getOrderItems()

    {

        return $this->hasMany(OrderItem::className(), ['order_id' => 'id']);

    }


    public function getItems()

    {

        return $this->hasMany(Item::className(), ['id' => 'item_id'])

            ->via('orderItems');

    }

}

As you can see, we already have separate relation for pivot table here (orderItems).

So the only thing you need to do is to get the values.

First, we create a property to hold these values, like this:


class Order extends \yii\db\ActiveRecord

{

    public $order_item_ids = [];

    ...

}

Then we fill it with actual stored values:




$record = Order::find($id);

$record->order_item_ids = \yii\helpers\ArrayHelper::getColumn(

    $record->getOrderItems()->asArray()->all(),

    'item_id'

);

(as you can see, I’ve used getColumn() from ArrayHelper class, to get rid of boring stuff)

Now we have selected ids in our order_item_ids property.

Let’s create the form field:


<?= $form->field($record, 'order_item_ids')->checkboxList($optionsList); ?>

Now we need to get list of options for our checkboxes. It’s pretty simple, too:


$list = Item::find()

    ->select(['id', 'name'])

    ->asArray()

    ->all();


$optionsList = \yii\helpers\ArrayHelper::map($list, 'id', 'name');

I’ve limited selected fields to [id, name] only and used asArray() to disable unnecessary DB->ActiveRecord conversion here.

Then I’ve called ArrayHelper::map() to convert result to required format.

ArrayHelper::map() is the new CHtml::listData() :)

Now we have checkbox list displaying correctly. What about saving?

Well, it’s up to you. It can be done in model’s afterSave:


foreach ($this->order_item_ids as $id) {

    $r = new OrderItem; // probably should be replaced with direct DAO call

    $r->order_id = $this->id;

    $r->item_id = $id;

    $r->save();

}

I’m pretty sure you can use link/unlink for that, but I’m against any magic, sorry :)

Notice that order_item_ids must be declared in validation rules (there’s a rule ‘safe’ for that, for example) if you’re planning to use mass-assignment.

Sometimes people ask how to use another version of jquery, bootstrap and so on.

It’s dead simple. You just need to configure assetManager component (in your application config file, under section ‘components’):


'assetManager' => [

    'bundles' => [

        'yii\web\JqueryAsset' => [

            'sourcePath' => null,

            'js' => ['//code.jquery.com/jquery-1.11.0-beta3.min.js'] // we'll take JQuery from CDN

        ],

        'yii\bootstrap\BootstrapPluginAsset' => [

            'sourcePath' => null,

            'js' => ['/js/bootstrap.bleeding.edge.version.js'] // and use some freakin new version of bootstrap, placed in /js folder

        ],

        'yii\bootstrap\BootstrapAsset' => [

            'sourcePath' => null,

            'css' => ['css/bootstrap.min.css', 'css/bootstrap-theme.css'] // customized BS styles

        ],

        'nodge\eauth\assets\WidgetAssetBundle' => [

            'css' => [], // and now we disable EAuth asset css, because we're planning to use our own styles. Js files remain untouched.

        ],

    ],

],

This way you can easily inject your own files in built-in assets.

Filtering data (the crazy way)

Let’s be honest: usual way of searching is crap.

Nobody wants a bunch of fields like id, name, email and so on. Actually all the users want is this.

Yeah, one field. And maybe some extra features, like in:spam (gmail webinterface).

And you know what? This can be done extremely easy using named scopes.

First of all, we create ActiveQuery class for our scopes. For those who missed that - yes, scopes are in AQ now, not in AR.

Let’s do it:


<?php

namespace app\models;


class ActiveQuery extends \yii\db\ActiveQuery

{

    public $filterIntercepted = false; // kinda flag showing that we've found a match


    public function filtered($q)

    {

        $model = $this->modelClass;

        if ($q) {

            if ($q == '#disabled') {

                // it's like google's in:spam query

                $this->andWhere([$model::tableName() . '.is_disabled' => 1]);

                $this->filterIntercepted = true;


            } elseif ($q == '#enabled') {

                $this->andWhere([$model::tableName() . '.is_disabled' => 0]);

                $this->filterIntercepted = true;


            } elseif (preg_match('/^[a-f0-9]{13}$/', $q)) {

                // поиск по ID

                $this->andWhere($model::tableName() . '.id = :q', [':q' => $q]);

                $this->filterIntercepted = true;

            }

        }


        return $this;

    }


    // some extra scopes

    public function disabled()

    {

        $model = $this->modelClass;

        $this->andWhere([$model::tableName() . '.is_disabled' => 1]);

        return $this;

    }


}



(I’m using $model::tableName() to prevent possible ‘field is ambigous’ error in case of joining relations)

Ok, so here I’ve created “filtered” scope that takes one parameter $q and tests it against some conditions.

$q looks like ID?.. If yes, then I set filterIntercepted flag to denote that filter is already ‘working’.

It doesn’t make much sense until I create scopes for actual models:


<?php

namespace app\models;


class ClientQuery extends \app\models\ActiveQuery

{

    public function filtered($q)

    {

        $query = parent::filtered($q); // here we test if $q is matching some basic conditions

        if (!$this->filterIntercepted) {

            // no match, let's test for something else

            $model = $this->modelClass;

            if (preg_match('/^\d+$/', $q)) {

                // looks like phone number?

                $query->andWhere([$model::tableName() . '.phone' => $q]);


            } elseif (strpos($q, '@') !== false) {

                // looks like email?

                $query->andWhere([$model::tableName() . '.email' => $q]);


            } else {

                // maybe it's a name

                $query->andWhere($model::tableName() . '.name LIKE :q', [':q' => '%' . $q . '%']);

            }

        }

        return $query;

    }


}

Now we can use it in our actionIndex for example:


public function actionIndex()

{

    $items = Client::find()

        ->joinWith('something') // remember the disambiguation?

        ->filtered(Yii::$app->request->getQueryParam('q'))

        ->all();


    ...

}



Bingo.

PS. Guys, srsly, this is crazy, so use it at your own risk.

These tips are awesome, thanks for creating this thread. I’m using the advanced template and I don’t have a web folder under config. I do have a web folder directly under backend and frontend, but I don’t see the asset manager component. The only thing I can see related is AppAsset.php:




namespace frontend\assets;


use yii\web\AssetBundle;


/**

 * @author Qiang Xue <qiang.xue@gmail.com>

 * @since 2.0

 */

class AppAsset extends AssetBundle

{

	public $basePath = '@webroot';

	public $baseUrl = '@web';

	public $css = [

		'css/site.css',

	];

	public $js = [

	];

	public $depends = [

		'yii\web\YiiAsset',

		'yii\bootstrap\BootstrapAsset',

	];

}






So I’m a little lost at the moment… I would like to use CDN version of bootstrap…

No, you got it wrong, sorry.

First of all, there are no web folder under config.

When I was speaking about “config/web.php” I meant application main config file (it’s called web.php in basic app template).

So when you need to configure components, you open your config file and find section ‘components’ there.


$config = [

    ...

    'extensions' => require(__DIR__ . '/../vendor/yiisoft/extensions.php'),

    'modules' => [

        'admin' => [

            'class' => 'app\modules\admin\Admin',

        ],

    ],

    // HERE IT IS:

    'components' => [

        'cache' => [

            'class' => 'MemCache',

            'servers' => [

                [

                    'host' => '127.0.0.1',

                    'port' => 11211,

                    'weight' => 100,

                ],

            ]

        ],

    ],

    ...

]

AppAsset is just an example asset bundle (it’s registered somewhere in your layout). So you just look at it, see that ‘yii\bootstrap\BootstrapAsset’ is used, then configure ‘yii\bootstrap\BootstrapAsset’ the way I showed before.

ORey, thanks for this thread, however i think some of complex examples or useful tips and tricks can go to official .md documentation. I would like to see it there since everybody first look docs and examples and only then go to search for forum thread. Not sure about what of current and future examples to include in docs, but that would be useful if you do so. Thanks )

Actually most of these things are ALREADY well-documented, it’s the API phpdocs.

What I’m doing here is saying: “Hey guys, check this out, I’ve found this (while digging the source or watching the commits) and I can use it this way, isn’t it cool?”.

So I suppose these examples are more like for "cookbook" or something.

Also I think I’ll post info on some new cool features here, that are documented in the guide (like this one - it’s VERY useful IMO), but since they’re new - somebody can easily miss them.

Anyway, feel free to use them as you like (for example this post was committed to docs eventually). Unfortunately my english is not good enough to write official docs.

Thanks Orey, that worked perfectly once I updated the correct file. I think with the advanced template, you have to do it for both frontend and backend in config/main.php.

Just my opinion, but I find your tips very useful and insightful. Often we can find out how something is done in docs, but not always why it is done or how cool or useful it really is. This is invaluable for someone who wants to learn and grow and this forum is the perfect place for that, so thanks again.

You are doing a great job mate. Keep it up.

BTW … not that it matters, your english is greater/better than many.

Ok, now it’s time to submit some forms.

First of all, we configure our ActiveForm slightly, to set our onsubmit listener.

Best way (the only way?..) to do it is beforeSubmit, it fires after client validation, but before actual submit.

Handler function takes one argument: "jqueryfied" form.


<?php $form = ActiveForm::begin([

    'id' => 'my-form-id', // Optional. I just like to keep things under control

    'validateOnType' => true, // Optional, but it helps to get rid of extra click

    'beforeSubmit' => new \yii\web\JsExpression('function($form) {

        $.post(

            $form.attr("action"),

            $form.serialize()

        )

        .done(function(result) {

            $form.parent().html(result);

        })

        .fail(function() {

            console.log("server error");

        });

        return false;

    }'),

]); ?>

As you can see, I’ve set beforeSubmit callback that calls well-known jquery’s ajax post().

I can take submit url from default form’s action using $form.attr(“action”).

In case of successful ajax call, I can take the result and do whatever I want. Here I’m just replacing form’s parent element.

If you hate writing JS inside PHP (as I do), you can just pass the name of the handler function:


<?php $form = ActiveForm::begin([

    'beforeSubmit' => 'postForm',

]); ?>



and then write actual processing code somewhere else:


<script>

function postForm($form) {

    $.post(

        $form.attr("action"),

        $form.serialize()

    )

    .done(function(result) {

        if (typeof(result) === "string") {

            // form is invalid, html returned

            $form.parent().html(result);

        } else {

            // form is valid and saved, json returned

            if (result.redirect) {

                window.location = result.redirect; // ok, that's weird, but who cares?..

            };

        }

    })

    .fail(function() {

        console.log("server error");

    });

    return false;

}

</script>

In this example I have extra check for typeof(result), so that I can return HTML or JSON from controller’s action.

Now the controller part.

Here’s simple action that renders the ‘_create’ partial view (that is, our form) in case of server validation failure,

or ‘_success’ partial view in case of saving.


public function actionCreate()

{

    $view = '_create';

    $record = new MyModel;


    if ($record->load($_POST) && $record->save()) {

        $record->refresh(); // just in case of some DB logic

        $view = '_success';

    }


    return $this->renderAjax($view, [

        'record' => $record,

    ]);

}

Or you can return HTML in case of error and JSON in case of success. Or something else. It’s up to you.


public function actionCreate()

{

    $record = new MyModel;


    if ($record->load($_POST) && $record->save()) {

        $record->refresh(); // just in case of some DB logic

        Yii::$app->response->format = 'json';

        return [

            'record' => $record->toArray(),

            'redirect' => Url::toRoute(['site/somewhere', 'from' => 'created']),

        ];

    }


    return $this->renderAjax('edit', [

        'record' => $record,

    ]);

}



As you have noticed, renderAjax() is used to render the partials.

It’s a special function that not only renders the view, but also adds JS and CSS files registered from the view.

Check this and this for details.

[b]BTW I would be happy if someone will review my code and tell me if I’m wrong, or there’s a better way.

Srsly guys.[/b]

And here’s how we can hack ActiveForm’s JS a litte bit.

As you probably know[color="#FF0000"]*[/color], you can use data-confirm attribute to prevent user from accidental mouse clicking:


<button type="submit" data-confirm="Are you sure?">delete this</button>

But who wants alerts? Alerts are crap.

Here’s what we do:


<script>

yii.allowAction = function ($e) {

    var message = $e.data('confirm');

    if (message === undefined) {

        return true; // not protected

    } else if ($e.data('_confirm')) {

        $e.html("Removing...");

        return true; // protected and confirmed

    } else {

        $e.html(message); // asking for confirmation RIGHT ON THE BUTTON

        $e.data('_confirm', true); // setting a flag

    }

    return false;

}<script>

In this case first click on the button changes it’s text to confirmation message, and only second passes.

I’m pretty sure you can create your own cool confirmation system this way.

[color="#FF0000"]*[/color] Actually, I found this by chance while creating my own implementation. So I just added data-confirm (without any js yet), clicked a button for some reason and OOPS WHAT WAS THAT? :blink:

ORey, currently it is hard to review code since you dont group it ) How about also to post this tips and tricks to coderwall ? That would be useful )

Hmm, never heard of that before.

You mean https://coderwall.com ?

Ok, I’ll take a look, thx.