Yii2 Data Widgets and DTOs

Basic Yii2 out of the box offers us an application architecture according to the MVC pattern (model, view, controller). For a more complex application, we resort to a clean architecture (you can see this article for a general idea) and within it, you need to abandon Active Record in templates (views), because AR is part of the database layer that the other layers don’t need to know about. Let’s assume that we want to continue using built-in widgets to display data in views: DeatilView, ListView and GridView. The last two use ActiveDataProviderwhich contains Active Record models in itself – the purpose of this article is to get rid of them and use only DTOs.

Application architecture

It is necessary to say a few words about the architecture that we get before moving on to the code.

Request path:

  1. Incoming request with data from the user

  2. The controller receives the data, prepares it (formatting and initial validation) and passes it to the service

  3. The service receives data from the controller and executes some business logic

  4. If the service needs data from the database, it accesses the repository

  5. The repository generates an SQL query, receives data from the database, packs it into a DTO and returns it to the service

  6. The service, after executing the business logic, returns data to the controller

  7. The controller using View Render (views) prepares HTML and returns to the user

In point 6, we allow the service to return data in ActiveDataProvider (in principle, any implementation of an interface DataProviderInterface), but only the data stored in it is not an Active Record model, but a Data Transfer Object (DTO).

Layers look like this:

  1. User Interface: controller (incoming data, preparing it, passing it to the service, outgoing data transformed through views)

  2. Business Logic: services with business logic

  3. Data Access: repositories for working with data (a private implementation is a repository for working with a database through Active Record)

The code

Training

To simplify, we will omit some architectural points (for example, that we must work with the repository interface, and not a specific implementation, that the data between each layer is its own DTO, etc.).

As an example, let’s create a blog application with articles.

File structure:

app
- - controllers
- - - - ArticleController.php
- - views
- - - - article
- - - - - - grid.php
- - - - - - list.php
- - - - - - list_item.php
- - - - - - detail.php
- - services
- - - - article
- - - - - - builders
- - - - - - - - ArticleDtoBuilder.php
- - - - - - dtos
- - - - - - - - ArticleDto.php
- - - - - - repositories
- - - - - - - - ArticleActiveRecord.php
- - - - - - - - ArticleDbRepository.php
- - - - - - ArticleService.php

Entities (AR and DTO)

When describing entity classes (Active Record and DTO), we will also duplicate the properties with constants, they will be useful to us when we need to refer to the property names in the form of strings, plus when refactoring, we can detect all uses in this way. Further it will be clearly clear.

The important difference is that in Active Record property names = column names in the database (in SnakeCase), while in DTO property names are in lowerCamelCase.

ArticleActiveRecord.php

<?php

namespace app\services\article\repositories;

/**
 * @property int    $id         Идентификатор
 * @property string $title      Название
 * @property string $text       Текст статьи
 * @property string $created_at Дата создания
 */
class ArticleActiveRecord extends \yii\db\ActiveRecord
{
    public const ATTR_ID         = 'id';
    public const ATTR_TITLE      = 'title';
    public const ATTR_TEXT       = 'text';
    public const ATTR_CREATED_AT = 'created_at';

    /**
     * @inheritDoc
     */
    public static function tableName(): string
    {
        return '{{%articles}}';
    }
}

ArticleDto.php

<?php

namespace app\services\article\dtos;

class ArticleDto
{
    public const ATTR_ID         = 'id';
    public const ATTR_TITLE      = 'title';
    public const ATTR_TEXT       = 'text';
    public const ATTR_CREATED_AT = 'createdAt';

    public function __construct(
        readonly public int $id,
        readonly public string $title,
        readonly public string $text,
        readonly public \DateTimeInterface $createdAt
    ) {
    }
}

Builder

ArticleDtoBuilder.php. Simple DTO builder from Active Record object(s).

<?php

namespace app\services\article\builders;

use app\services\article\dtos\ArticleDto;
use app\services\article\repositories\ArticleActiveRecord;

class ArticleDtoBuilder
{
    public static function buildFromActiveRecord(ArticleActiveRecord $activeRecord): ArticleDto
    {
        return new ArticleDto(
            $activeRecord->id,
            $activeRecord->title,
            $activeRecord->text,
            new \DateTimeImmutable($activeRecord->created_at),
        );
    }

    /**
     * @param ArticleActiveRecord[] $activeRecords
     *
     * @return ArticleDto[]
     */
    public static function buildFromActiveRecords(array $activeRecords): array
    {
        $dtos = [];
        foreach ($activeRecords as $activeRecord) {
            if (!($activeRecord instanceof ArticleActiveRecord)) {
                continue;
            }
            $dtos[] = self::buildFromActiveRecord($activeRecord);
        }
        
        return $dtos;
    }
}

repository

ArticleDbRepository.php. Let’s move on to the repository, there are just a few main points going on in it:

  1. We set an attribute map for sorting, where the keys are the names of properties from the DTO. So we will be able to refer further in our widgets to the properties of the DTO, and not the Active Record. This will also allow the sorting widget to use the DTO property names in the column names, and not the actual column names from the database tables and in the class Sort transfer them exactly, and based on this map, he himself will understand what to add to the SQL query.

  2. We overwrite all AR objects (within the current selection, current page, etc.) to our DTOs using the builder.

<?php

namespace app\services\article\repositories;

use app\services\article\builders\ArticleDtoBuilder;
use app\services\article\dtos\ArticleDto;
use yii\data\ActiveDataProvider;

class ArticleRepository
{
    public function findAllAsDataProvider(int $pageSize = 20): ActiveDataProvider
    {
        # Создаем Data Provider с картой для сортировки
        $dataProvider = new ActiveDataProvider([
            'query'      => ArticleActiveRecord::find(),
            'pagination' => [
                'pageSize' => $pageSize ?: false,
            ],
            'sort'       => [
                'defaultOrder' => [ArticleDto::ATTR_CREATED_AT => SORT_DESC],
                'attributes'   => [
                    ArticleDto::ATTR_ID           => [
                        'asc'     => [ArticleActiveRecord::ATTR_ID => SORT_ASC],
                        'desc'    => [ArticleActiveRecord::ATTR_ID => SORT_DESC],
                        'default' => SORT_ASC,
                    ],
                    ArticleDto::ATTR_TITLE      => [
                        'asc'     => [ArticleActiveRecord::ATTR_TITLE => SORT_ASC],
                        'desc'    => [ArticleActiveRecord::ATTR_TITLE => SORT_DESC],
                        'default' => SORT_ASC,
                    ],
                    ArticleDto::ATTR_TEXT        => [
                        'asc'     => [ArticleActiveRecord::ATTR_TEXT => SORT_ASC],
                        'desc'    => [ArticleActiveRecord::ATTR_TEXT => SORT_DESC],
                        'default' => SORT_ASC,
                    ],
                    ArticleDto::ATTR_CREATED_AT => [
                        'asc'     => [ArticleActiveRecord::ATTR_CREATED_AT => SORT_ASC],
                        'desc'    => [ArticleActiveRecord::ATTR_CREATED_AT => SORT_DESC],
                        'default' => SORT_ASC,
                    ],
                ],
            ],
        ]);

        $dataProvider->setModels(ArticleDtoBuilder::buildFromActiveRecords($dataProvider->getModels()));

        return $dataProvider;
    }
}

Service

ArticleService.php. The service code in this example is not important to us and we will omit some business logic and just immediately turn to the repository for data. Plus, this is where we apply the simplifications described above (the interface for the repository, plus the data crosses the boundaries of the layer, etc.).

<?php

namespace app\services\article;

use app\services\article\repositories\ArticleRepository;
use yii\data\ActiveDataProvider;

class ArticleService
{
    public function __construct(
        readonly private ArticleRepository $repository
    ) {
    }

    public function getAllAsDataProvider(): ActiveDataProvider
    {
        // Дополнительная логика, например закэшировать. В текущем примере ничего не делаем.
        return $this->repository->findAllAsDataProvider();
    }
}

Controller

ArticleController.php. Has 3 methods for each of the widgets. To simplify for the DeatilView widget, we will take one element directly from the data provider.

<?php

namespace app\controllers;

use app\services\article\ArticleService;
use app\services\article\dtos\ArticleDto;
use yii\data\ActiveDataProvider;
use yii\web\Controller;

class ArticleController extends Controller
{
    public function __construct($id, $module, readonly private ArticleService $articleService, $config = [])
    {
        parent::__construct($id, $module, $config);
    }

    public function actionGrid(): string
    {
        return $this->render('grid', ['dataProvider' => $this->getDataProvider()]);
    }

    public function actionList(): string
    {
        return $this->render('list', ['dataProvider' => $this->getDataProvider()]);
    }

    public function actionDetail(): string
    {
        /** @var ArticleDto[] $articles */
        $articles = $this->getDataProvider()->getModels();

        return $this->render('detail', ['article' => array_shift($articles)]);
    }

    private function getDataProvider(): ActiveDataProvider
    {
        return $this->articleService->getAllAsDataProvider();
    }
}

Widgets

gridview

app/views/grid.php (Controller::actionGrid())

AT $dataProvider we have our provider with our DTOs. In the widget, now we operate with DTO property names and completely forget about Active Record. When it is necessary to do something with a value, an object of the class is passed to the anonymous function ArticleDto.

<?php

use app\services\article\dtos\ArticleDto;
use yii\data\ActiveDataProvider;
use yii\grid\GridView;
use yii\helpers\StringHelper;
use yii\web\View;

/**
 * @var View               $this
 * @var ActiveDataProvider $dataProvider
 */
?>

<?= GridView::widget([
    'dataProvider' => $dataProvider,
    'columns'      => [
        [
            'attribute' => ArticleDto::ATTR_ID,
            'label'     => Yii::t('app', 'ID'),
        ],
        [
            'attribute' => ArticleDto::ATTR_TITLE,
            'label'     => Yii::t('app', 'Заголовок'),
            'format'    => 'raw',
            'value'     => function (ArticleDto $article) {
                return StringHelper::truncate($article->title, 50);
            },
        ],
        [
            'attribute' => ArticleDto::ATTR_TEXT,
            'label'     => Yii::t('app', 'Текст'),
            'format'    => 'raw',
            'value'     => function (ArticleDto $article) {
                return StringHelper::truncate($article->title, 200);
            },
        ],
        [
            'attribute' => ArticleDto::ATTR_CREATED_AT,
            'label'     => Yii::t('app', 'Дата создания'),
            'value'     => function (ArticleDto $article) {
                return $article->createdAt->format('Y-m-d');
            },
        ],
    ],
]); ?>

list view

app/views/list.php (Controller::actionList())

The incoming data is the same, the difference is only in the use of the widget itself and that a DTO is passed to a separate view responsible for rendering 1 record.

<?php

use yii\data\ActiveDataProvider;
use yii\web\View;
use yii\widgets\ListView;

/**
 * @var View               $this
 * @var ActiveDataProvider $dataProvider
 */
?>

<?= ListView::widget([
    'dataProvider' => $dataProvider,
    'itemView'     => 'list_item',
]); ?>

app/views/list_item.php

<?php

use app\services\article\dtos\ArticleDto;
use yii\web\View;

/**
 * @var View       $this
 * @var ArticleDto $model
 */
?>

<h1><?= $model->title ?></h1>
<div><?= $model->text ?></div>

Detail View

app/views/detail.php (Controller::actionDetail())

The DTO is immediately passed to the view and the same object is passed to the widget (as a parameter model).

<?php

use app\services\article\dtos\ArticleDto;
use yii\web\View;
use yii\widgets\DetailView;

/**
 * @var View       $this
 * @var ArticleDto $article
 */
?>

<?= DetailView::widget([
    'model'      => $article,
    'attributes' => [
        [
            'attribute' => ArticleDto::ATTR_ID,
            'label'     => Yii::t('app', 'Идентификатор'),
        ],
        ArticleDto::ATTR_TITLE,
        ArticleDto::ATTR_TEXT . ':html',
        [
            'label' => Yii::t('app', 'Дата создания'),
            'value' => $article->createdAt->format('Y-m-d'),
        ],
    ],
]) ?>

Outcome

Briefly it turns out:

  • it is necessary to replace all Active Record objects in the data provider with our DTOs

  • build an attribute map for sorting (GridView) and for operating in widgets with the name of DTO properties, and not Active Record

  • in widgets, when specifying attribute names, the name of the DTO properties is used

Similar Posts

Leave a Reply