Search icon CANCEL
Subscription
0
Cart icon
Your Cart (0 item)
Close icon
You have no products in your basket yet
Arrow left icon
Explore Products
Best Sellers
New Releases
Books
Videos
Audiobooks
Learning Hub
Free Learning
Arrow right icon

Extending Yii

Save for later
  • 14 min read
  • 03 Oct 2016

article-image

Introduction     

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.)

Helpers

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.

Getting ready

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.

How to do it…

  1. Create the helpers directory in your project and write the NumberHelper class:
    <?php
    namespace apphelpers;
    
    class NumberHelper
    {
       public static function format($value, $decimal = 2)
       {
           return number_format($value, $decimal, '.', ',');
       }
    }
    
    
  2. Add the actionNumbers method into SiteController:
    <?php
    ...
    class SiteController extends Controller
    {
       …
    
       public function actionNumbers()
       {
           return $this->render('numbers', ['value' => 18878334526.3]);
       }
    }
    
  3. Add the views/site/numbers.php view:
    <?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>
    
  4. Open the action and see this result:

    extending-yii-img-0

  5. In other cases you can specify another count of decimal numbers; for example:
    NumberHelper::format($value, 3)

How it works…

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.

See also

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.

Creating model behaviors

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.

Getting ready

  1. 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.
  2. Create two databases for working and for tests.
  3. Configure Yii to use the first database in your primary application in config/db.php. Make sure the test application uses a second database in tests/codeception/config/config.php.
  4. Create a new migration:
    <?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}}');
       }
    }
    
  5. Apply the migration to both working and testing databases:
    ./yii migrate
    tests/codeception/bin/yii migrate
    
  6. Create a Post model:
    <?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],
            ];
        }
    }
    

How to do it…

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';
}
  1. Add a fixture data file in 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",
        ],
    ];
    
  2. Then, we need to create a test case tests/codeception/unit/MarkdownBehaviorTest:

    .

    .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(),
                ]
            ];
        }
    }
    
  3. Run unit tests:
    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
    
  4. Now we need to implement a behavior, attach it to the model, and make sure the test passes. Create a new directory, behaviors. Under this directory, create the MarkdownBehavior class:
    <?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);
       }
    }
    
  5. Let's attach the behavior to the Post model:
    class Post extends ActiveRecord
    {
       ...
    
       public function behaviors()
       {
           return [
               'markdown' => [
                   'class' => MarkdownBehavior::className(),
                   'sourceAttribute' => 'content_markdown',
                   'targetAttribute' => 'content_html',
               ],
           ];
       }
    }
    
  6. Run the test and make sure it passes:
    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
    
  7. That's it. We've created a reusable behavior and can use it for all future projects by just connecting it to a model.

How it works…

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:

  • First, we test processing new model content. The behavior must convert Markdown text from a source attribute to HTML and store the second one to target attribute.
  • Second, we test updated content of an existing model. After changing Markdown content and saving the model, we must get updated HTML content.

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:

Unlock access to the largest independent learning library in Tech for FREE!
Get unlimited access to 7500+ expert-authored eBooks and video courses covering every tech area you can think of.
Renews at AU $24.99/month. Cancel anytime
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.

See also

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/.

Creating components

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.

Getting ready

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.

How to do it…

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.

  1. Create a components directory in your application structure.
  2. Create the component class example with the following interface:
    <?php
    namespace appcomponents;
    
    use yiibaseComponent;
    
    class Exchange extends Component
    {
        public function getRate($source, $destination, $date = null)
        {
    
        }
    }
    
  3. Implement the component functional:
    <?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];
       }
    }
    
  4. Attach our component in the config/console.php or config/web.php configuration files:
    'components' => [
       'cache' => [
           'class' => 'yiicachingFileCache',
       ],
       'exchange' => [
            'class' => 'appcomponentsExchange',
            'enableCaching' => true,
        ],
        // ...
        db' => $db,
    ],
    
  5. We can now use a new component directly or via a get method:
    echo Yii::$app->exchange->getRate('USD', 'EUR');
    echo Yii::$app->get('exchange')->getRate('USD', 'EUR', '2014-04-12');
  6. Create a demonstration console controller:
    <?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;
    }
    }
  7. And try to run any commands:
    $ ./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.

Overriding existing application components

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:

  1. Extend the yiii18nFormatter component like the following:
    <?php
    namespace appcomponents;
    
    class Formatter extends yiii18nFormatter
    {
       public function asNumber($value, $decimal = 2)
       {
           return number_format($value, $decimal, '.', ',');
       }
    }
    
  2. Override the class of the built-in formatter component:
    'components' => [
        // ...
        formatter => [
            'class' => 'appcomponentsFormatter,
        ],
        // …
    ],
    
  3. Right now, we can use this method directly:
    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',
        ],
    ]) ?>
    
  4. You can also extend every existing component without overwriting its source code.

How it works…

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.

See also

Summary

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.

Resources for Article:


Further resources on this subject: