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 ActiveDataProvider
which 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:
Incoming request with data from the user
The controller receives the data, prepares it (formatting and initial validation) and passes it to the service
The service receives data from the controller and executes some business logic
If the service needs data from the database, it accesses the repository
The repository generates an SQL query, receives data from the database, packs it into a DTO and returns it to the service
The service, after executing the business logic, returns data to the controller
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:
User Interface: controller (incoming data, preparing it, passing it to the service, outgoing data transformed through views)
Business Logic: services with business logic
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:
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.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