In this article by Dmitry Eliseev, the author of the book Yii Application Development Cookbook Third Edition, we will see three Yii extensions—helpers, behaviors, and components. In addition, we will learn how to make your extension reusable and useful for the community and will focus on the many things you should do in order to make your extension as efficient as possible.
(For more resources related to this topic, see here.)
There are a lot of built-in framework helpers, like StringHelper in the yiihelpers namespace. It contains sets of helpful static methods for manipulating strings, files, arrays, and other subjects.
In many cases, for additional behavior you can create your own helper and put any static functions into one. For example, we will implement a number helper in this recipe.
Create a new yii2-app-basic application by using composer, as described in the official guide at http://www.yiiframework.com/doc-2.0/guide-start-installation.html.
<?php
namespace apphelpers;
class NumberHelper
{
public static function format($value, $decimal = 2)
{
return number_format($value, $decimal, '.', ',');
}
}
<?php
...
class SiteController extends Controller
{
…
public function actionNumbers()
{
return $this->render('numbers', ['value' => 18878334526.3]);
}
}
<?php
use apphelpersNumberHelper;
use yiihelpersHtml;
/* @var $this yiiwebView */
/* @var $value float */
$this->title = 'Numbers';
$this->params['breadcrumbs'][] = $this->title;
?>
<div class="site-numbers">
<h1><?= Html::encode($this->title) ?></h1>
<p>
Raw number:<br />
<b><?= $value ?></b>
</p>
<p>
Formatted number:<br />
<b><?= NumberHelper::format($value) ?></b>
</p>
</div>
NumberHelper::format($value, 3)
Any helper in Yii2 is just a set of functions implemented as static methods in corresponding classes.
You can use one to implement any different format of output for manipulations with values of any variable, and for other cases.
Note: Usually, static helpers are light-weight clean functions with a small count of arguments. Avoid putting your business logic and other complicated manipulations into helpers . Use widgets or other components instead of helpers in other cases.
For more information about helpers, refer to http://www.yiiframework.com/doc-2.0/guide-helper-overview.html.
And for examples of built-in helpers, see sources in the helpers directory of the framework, refer to https://github.com/yiisoft/yii2/tree/master/framework/helpers.
There are many similar solutions in today's web applications. Leading products such as Google's Gmail are defining nice UI patterns; one of these is soft delete. Instead of a permanent deletion with multiple confirmations, Gmail allows users to immediately mark messages as deleted and then easily undo it. The same behavior can be applied to any object such as blog posts, comments, and so on.
Let's create a behavior that will allow marking models as deleted, restoring models, selecting not yet deleted models, deleted models, and all models. In this recipe we'll follow a test-driven development approach to plan the behavior and test if the implementation is correct.
<?php
use yiidbMigration;
class m160427_103115_create_post_table extends Migration
{
public function up()
{
$this->createTable('{{%post}}', [
'id' => $this->primaryKey(),
'title' => $this->string()->notNull(),
'content_markdown' => $this->text(),
'content_html' => $this->text(),
]);
}
public function down()
{
$this->dropTable('{{%post}}');
}
}
./yii migrate
tests/codeception/bin/yii migrate
<?php
namespace appmodels;
use appbehaviorsMarkdownBehavior;
use yiidbActiveRecord;
/**
* @property integer $id
* @property string $title
* @property string $content_markdown
* @property string $content_html
*/
class Post extends ActiveRecord
{
public static function tableName()
{
return '{{%post}}';
}
public function rules()
{
return [
[['title'], 'required'],
[['content_markdown'], 'string'],
[['title'], 'string', 'max' => 255],
];
}
}
Let's prepare a test environment, starting with defining the fixtures for the Post model. Create the tests/codeception/unit/fixtures/PostFixture.php file:
<?php
namespace apptestscodeceptionunitfixtures;
use yiitestActiveFixture;
class PostFixture extends ActiveFixture
{
public $modelClass = 'appmodelsPost';
public $dataFile = '@tests/codeception/unit/fixtures/data/post.php';
}
<?php
return [
[
'id' => 1,
'title' => 'Post 1',
'content_markdown' => 'Stored *markdown* text 1',
'content_html' => "<p>Stored <em>markdown</em> text 1</p>n",
],
];
.
.php:
<?php
namespace apptestscodeceptionunit;
use appmodelsPost;
use apptestscodeceptionunitfixturesPostFixture;
use yiicodeceptionDbTestCase;
class MarkdownBehaviorTest extends DbTestCase
{
public function testNewModelSave()
{
$post = new Post();
$post->title = 'Title';
$post->content_markdown = 'New *markdown* text';
$this->assertTrue($post->save());
$this->assertEquals("<p>New <em>markdown</em> text</p>n", $post->content_html);
}
public function testExistingModelSave()
{
$post = Post::findOne(1);
$post->content_markdown = 'Other *markdown* text';
$this->assertTrue($post->save());
$this->assertEquals("<p>Other <em>markdown</em> text</p>n", $post->content_html);
}
public function fixtures()
{
return [
'posts' => [
'class' => PostFixture::className(),
]
];
}
}
codecept run unit MarkdownBehaviorTest
and ensure that tests have not passed
Codeception PHP Testing Framework v2.0.9
Powered by PHPUnit 4.8.27 by Sebastian Bergmann and contributors.
Unit Tests (2) ---------------------------------------------------------------------------
Trying to test ... MarkdownBehaviorTest::testNewModelSave Error
Trying to test ... MarkdownBehaviorTest::testExistingModelSave Error
---------------------------------------------------------------------------
Time: 289 ms, Memory: 16.75MB
<?php
namespace appbehaviors;
use yiibaseBehavior;
use yiibaseEvent;
use yiibaseInvalidConfigException;
use yiidbActiveRecord;
use yiihelpersMarkdown;
class MarkdownBehavior extends Behavior
{
public $sourceAttribute;
public $targetAttribute;
public function init()
{
if (empty($this->sourceAttribute) || empty($this->targetAttribute)) {
throw new InvalidConfigException('Source and target must be set.');
}
parent::init();
}
public function events()
{
return [
ActiveRecord::EVENT_BEFORE_INSERT => 'onBeforeSave',
ActiveRecord::EVENT_BEFORE_UPDATE => 'onBeforeSave',
];
}
public function onBeforeSave(Event $event)
{
if ($this->owner->isAttributeChanged($this->sourceAttribute)) {
$this->processContent();
}
}
private function processContent()
{
$model = $this->owner;
$source = $model->{$this->sourceAttribute};
$model->{$this->targetAttribute} = Markdown::process($source);
}
}
class Post extends ActiveRecord
{
...
public function behaviors()
{
return [
'markdown' => [
'class' => MarkdownBehavior::className(),
'sourceAttribute' => 'content_markdown',
'targetAttribute' => 'content_html',
],
];
}
}
Codeception PHP Testing Framework v2.0.9
Powered by PHPUnit 4.8.27 by Sebastian Bergmann and contributors.
Unit Tests (2) ---------------------------------------------------------------------------
Trying to test ... MarkdownBehaviorTest::testNewModelSave Ok
Trying to test ... MarkdownBehaviorTest::testExistingModelSave Ok
---------------------------------------------------------------------------
Time: 329 ms, Memory: 17.00MB
Let's start with the test case. Since we want to use a set of models, we will define fixtures. A fixture set is put into the DB each time the test method is executed.
We will prepare unit tests for specifying how the behavior works:
Now let's move to the interesting implementation details. In behavior, we can add our own methods that will be mixed into the model that the behavior is attached to. We can also subscribe to our own component events. We are using it to add our own listener:
public function events()
{
return [
ActiveRecord::EVENT_BEFORE_INSERT => 'onBeforeSave',
ActiveRecord::EVENT_BEFORE_UPDATE => 'onBeforeSave',
];
}
And now we can implement this listener:
public function onBeforeSave(Event $event)
{
if ($this->owner->isAttributeChanged($this->sourceAttribute)) {
$this->processContent();
}
}
In all methods, we can use the owner property to get the object the behavior is attached to. In general we can attach any behavior to your models, controllers, application, and other components that extend the yiibaseComponent class. We can also attach one behavior again and again to model for the processing of different attributes:
class Post extends ActiveRecord
{
...
public function behaviors()
{
return [
[
'class' => MarkdownBehavior::className(),
'sourceAttribute' => 'description_markdown',
'targetAttribute' => 'description_html',
],
[
'class' => MarkdownBehavior::className(),
'sourceAttribute' => 'content_markdown',
'targetAttribute' => 'content_html',
],
];
}
}
Besides, we can also extend the yiibaseAttributeBehavior class, like yiibehaviorsTimestampBehavior, to update specified attributes for any event.
To learn more about behaviors and events, refer to the following pages:
For more information about Markdown syntax, refer to http://daringfireball.net/projects/markdown/.
If you have some code that looks like it can be reused but you don't know if it's a behavior, widget, or something else, it's most probably a component. The component should be inherited from the yiibaseComponent class. Later on, the component can be attached to the application and configured using the components section of a configuration file. That's the main benefit compared to using just a plain PHP class. We are also getting behaviors, events, getters, and setters support.
For our example, we'll implement a simple Exchange application component that will be able to get currency rates from the http://fixer.io site, attach them to the application, and use them.
Create a new yii2-app-basic application by using composer, as described in the official guide at http://www.yiiframework.com/doc-2.0/guide-start-installation.html.
To get a currency rate, our component should send an HTTP GET query to a service URL, like http://api.fixer.io/2016-05-14?base=USD.
The service must return all supported rates on the nearest working day:
{
"base":"USD",
"date":"2016-05-13",
"rates": {
"AUD":1.3728,
"BGN":1.7235,
...
"ZAR":15.168,
"EUR":0.88121
}
}
The component should extract needle currency from the response in a JSON format and return a target rate.
<?php
namespace appcomponents;
use yiibaseComponent;
class Exchange extends Component
{
public function getRate($source, $destination, $date = null)
{
}
}
<?php
namespace appcomponents;
use yiibaseComponent;
use yiibaseInvalidConfigException;
use yiibaseInvalidParamException;
use yiicachingCache;
use yiidiInstance;
use yiihelpersJson;
class Exchange extends Component
{
/**
* @var string remote host
*/
public $host = 'http://api.fixer.io';
/**
* @var bool cache results or not
*/
public $enableCaching = false;
/**
* @var string|Cache component ID
*/
public $cache = 'cache';
public function init()
{
if (empty($this->host)) {
throw new InvalidConfigException('Host must be set.');
}
if ($this->enableCaching) {
$this->cache = Instance::ensure($this->cache, Cache::className());
}
parent::init();
}
public function getRate($source, $destination, $date = null)
{
$this->validateCurrency($source);
$this->validateCurrency($destination);
$date = $this->validateDate($date);
$cacheKey = $this->generateCacheKey($source, $destination, $date);
if (!$this->enableCaching || ($result = $this->cache->get($cacheKey)) === false) {
$result = $this->getRemoteRate($source, $destination, $date);
if ($this->enableCaching) {
$this->cache->set($cacheKey, $result);
}
}
return $result;
}
private function getRemoteRate($source, $destination, $date)
{
$url = $this->host . '/' . $date . '?base=' . $source;
$response = Json::decode(file_get_contents($url));
if (!isset($response['rates'][$destination])) {
throw new RuntimeException('Rate not found.');
}
return $response['rates'][$destination];
}
private function validateCurrency($source)
{
if (!preg_match('#^[A-Z]{3}$#s', $source)) {
throw new InvalidParamException('Invalid currency format.');
}
}
private function validateDate($date)
{
if (!empty($date) && !preg_match('#d{4}-d{2}-d{2}#s', $date)) {
throw new InvalidParamException('Invalid date format.');
}
if (empty($date)) {
$date = date('Y-m-d');
}
return $date;
}
private function generateCacheKey($source, $destination, $date)
{
return [__CLASS__, $source, $destination, $date];
}
}
'components' => [
'cache' => [
'class' => 'yiicachingFileCache',
],
'exchange' => [
'class' => 'appcomponentsExchange',
'enableCaching' => true,
],
// ...
db' => $db,
],
echo Yii::$app->exchange->getRate('USD', 'EUR');
echo Yii::$app->get('exchange')->getRate('USD', 'EUR', '2014-04-12');
<?php
namespace appcommands;
use yiiconsoleController;
class ExchangeController extends Controller
{
public function actionTest($currency, $date = null)
{
echo Yii::$app->exchange->getRate('USD', $currency, $date) . PHP_EOL;
}
}
$ ./yii exchange/test EUR
> 0.90196
$ ./yii exchange/test EUR 2015-11-24
> 0.93888
$ ./yii exchange/test OTHER
> Exception 'yiibaseInvalidParamException' with message 'Invalid currency format.'
$ ./yii exchange/test EUR 2015/24/11
Exception 'yiibaseInvalidParamException' with message 'Invalid date format.'
$ ./yii exchange/test ASD
> Exception 'RuntimeException' with message 'Rate not found.'
As a result you must see rate values in success cases or specific exceptions in error ones. In addition to creating your own components, you can do more.
Most of the time there will be no need to create your own application components, since other types of extensions, such as widgets or behaviors, cover almost all types of reusable code. However, overriding core framework components is a common practice and can be used to customize the framework's behavior for your specific needs without hacking into the core.
For example, to be able to format numbers using the Yii::app()->formatter->asNumber($value) method instead of the NumberHelper::format method from the Helpers recipe, follow the next steps:
<?php
namespace appcomponents;
class Formatter extends yiii18nFormatter
{
public function asNumber($value, $decimal = 2)
{
return number_format($value, $decimal, '.', ',');
}
}
'components' => [
// ...
formatter => [
'class' => 'appcomponentsFormatter,
],
// …
],
echo Yii::app()->formatter->asNumber(1534635.2, 3);
or as a new format for GridView and DetailView widgets:
<?= yiigridGridView::widget([
'dataProvider' => $dataProvider,
'columns' => [
'id',
'created_at:datetime',
'title',
'value:number',
],
]) ?>
To be able to attach a component to an application it can be extended from the yiibaseComponent class. Attaching is as simple as adding a new array to the components’ section of configuration. There, a class value specifies the component's class and all other values are set to a component through the corresponding component's public properties and setter methods.
Implementation itself is very straightforward; We are wrapping http://api.fixer.io calls into a comfortable API with validators and caching. We can access our class by its component name using Yii::$app. In our case, it will be Yii::$app->exchange.
In this article we learnt about the Yii extensions—helpers, behavior, and components. Helpers contains sets of helpful static methods for manipulating strings, files, arrays, and other subjects. Behaviors allow you to enhance the functionality of an existing component class without needing to change the class's inheritance. Components are the main building blocks of Yii applications. A component is an instance of CComponent or its derived class. Using a component mainly involves accessing its properties and raising/handling its events.
Further resources on this subject: