Venho usando Yii3 em alguns projetos pessoais, e quase todo tutorial de "primeiro CRUD" que eu acho ainda é de Laravel. Então resolvi escrever um para Yii3.
Se você vem do Yii2, esquece o Yii::$app. O Yii3 é PSR de ponta a ponta, e tudo chega nas suas classes pelo construtor. É mais explícito, e isso deixa o código mais fácil de acompanhar, porque nada acontece escondido.
Vamos montar um gerenciador de posts simples, com listar, criar, editar e apagar. Ele usa SQLite e o servidor embutido do PHP, então você só precisa de PHP 8.2+ (com pdo_sqlite) e Composer.
Passo 1: Criar o projeto
composer create-project yiisoft/app crud-app
cd crud-app
cp .env.example .env
./yii serve
Abra http://127.0.0.1:8080 e você deve ver a página de boas-vindas do Yii3. No Windows, use yii.bat no lugar de ./yii.
Copiar o .env coloca o app em modo dev. Assim os erros aparecem com stack trace, em vez de uma página 500 genérica.
Estas são as pastas que vamos mexer:
config/common/di/ # serviços, todo arquivo aqui é carregado
config/common/routes.php
config/console/params.php
src/ # seu código, namespace App\
runtime/ # logs, cache e o arquivo do SQLite
Passo 2: Instalar os pacotes de banco e de formulário
O template não vem com camada de banco nem formulários, então a gente adiciona:
composer require yiisoft/db-sqlite yiisoft/db-migration yiisoft/cache yiisoft/form-model
O db-sqlite traz a conexão e o query builder. O db-migration adiciona os comandos ./yii migrate:*. O form-model traz as classes de formulário, a validação e os widgets de campo. O cache só está aí porque a conexão pede um cache de schema.
Passo 3: Configurar a conexão com o banco
No Yii3, configurar algo é dizer ao container de DI como construir aquilo. Crie config/common/di/db.php:
<?php
declare(strict_types=1);
use Yiisoft\Aliases\Aliases;
use Yiisoft\Cache\ArrayCache;
use Yiisoft\Db\Cache\SchemaCache;
use Yiisoft\Db\Connection\ConnectionInterface;
use Yiisoft\Db\Sqlite\Connection;
use Yiisoft\Db\Sqlite\Driver;
return [
SchemaCache::class => [
'__construct()' => [new ArrayCache()],
],
ConnectionInterface::class => static fn (Aliases $aliases, SchemaCache $schemaCache) => new Connection(
new Driver('sqlite:' . $aliases->get('@runtime/database.sqlite')),
$schemaCache,
),
];
A partir daqui, qualquer classe que pedir ConnectionInterface no construtor recebe essa conexão.
Depois, diga à ferramenta de migration onde elas ficam. Em config/console/params.php:
<?php
declare(strict_types=1);
return [
'yiisoft/yii-console' => [
'commands' => require __DIR__ . '/commands.php',
],
'yiisoft/db-migration' => [
'newMigrationNamespace' => 'App\Migration',
'sourceNamespaces' => ['App\Migration'],
],
];
Passo 4: Criar a tabela post
mkdir -p src/Migration
./yii migrate:create post --command=table --fields="title:string(200):notNull,body:text:notNull,created_at:datetime:notNull,updated_at:datetime:notNull"
O gerador cria algo como src/Migration/M261008033620CreatePostTable.php:
final class M261008033620CreatePostTable implements RevertibleMigrationInterface, TransactionalMigrationInterface
{
public function up(MigrationBuilder $b): void
{
$columnBuilder = $b->columnBuilder();
$b->createTable('post', [
'id' => $columnBuilder::primaryKey(),
'title' => $columnBuilder::string(200)->notNull(),
'body' => $columnBuilder::text()->notNull(),
'created_at' => $columnBuilder::datetime()->notNull(),
'updated_at' => $columnBuilder::datetime()->notNull(),
]);
}
public function down(MigrationBuilder $b): void
{
$b->dropTable('post');
}
}
Rode a migration:
./yii migrate:up
Agora o runtime/database.sqlite existe, já com a tabela.
Passo 5: O repository
O Yii3 não te empurra um ORM. Para algo desse tamanho, uma classe pequena usando o query builder resolve. Crie src/Post/PostRepository.php:
<?php
declare(strict_types=1);
namespace App\Post;
use Yiisoft\Db\Connection\ConnectionInterface;
final readonly class PostRepository
{
public function __construct(
private ConnectionInterface $db,
) {}
/**
* @return array[] Newest posts first.
*/
public function findAll(): array
{
return $this->db->select()->from('post')->orderBy(['id' => SORT_DESC])->all();
}
public function findById(int $id): ?array
{
return $this->db->select()->from('post')->where(['id' => $id])->one();
}
public function create(string $title, string $body): int
{
$now = date('Y-m-d H:i:s');
$this->db->createCommand()->insert('post', [
'title' => $title,
'body' => $body,
'created_at' => $now,
'updated_at' => $now,
])->execute();
return (int) $this->db->getLastInsertId();
}
public function update(int $id, string $title, string $body): void
{
$this->db->createCommand()->update('post', [
'title' => $title,
'body' => $body,
'updated_at' => date('Y-m-d H:i:s'),
], ['id' => $id])->execute();
}
public function delete(int $id): void
{
$this->db->createCommand()->delete('post', ['id' => $id])->execute();
}
}
Essa classe não precisa de configuração nenhuma. O container vê ConnectionInterface no construtor e entrega a conexão. Os valores vão como parâmetros (bind), então SQL injection não é problema aqui. Se você prefere Active Record, existe o yiisoft/active-record.
Passo 6: O formulário
O formulário é uma classe, e as regras de validação são atributos nas propriedades. Crie src/Web/Post/PostForm.php:
<?php
declare(strict_types=1);
namespace App\Web\Post;
use Yiisoft\FormModel\FormModel;
use Yiisoft\Validator\Rule\Length;
use Yiisoft\Validator\Rule\Required;
final class PostForm extends FormModel
{
#[Required]
#[Length(min: 3, max: 200, skipOnEmpty: true)]
public string $title = '';
#[Required]
#[Length(min: 10, skipOnEmpty: true)]
public string $body = '';
}
Sem o skipOnEmpty, um campo vazio mostraria dois erros: o "cannot be blank" e o de tamanho.
Passo 7: O controller
Crie src/Web/Post/PostController.php:
<?php
declare(strict_types=1);
namespace App\Web\Post;
use App\Post\PostRepository;
use App\Web\NotFound\NotFoundHandler;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Yiisoft\FormModel\FormHydrator;
use Yiisoft\Http\Header;
use Yiisoft\Http\Status;
use Yiisoft\Router\HydratorAttribute\RouteArgument;
use Yiisoft\Router\UrlGeneratorInterface;
use Yiisoft\Session\Flash\FlashInterface;
use Yiisoft\Yii\View\Renderer\WebViewRenderer;
final readonly class PostController
{
private WebViewRenderer $viewRenderer;
public function __construct(
WebViewRenderer $viewRenderer,
private PostRepository $posts,
private FormHydrator $formHydrator,
private ResponseFactoryInterface $responseFactory,
private UrlGeneratorInterface $urlGenerator,
private FlashInterface $flash,
private NotFoundHandler $notFoundHandler,
) {
$this->viewRenderer = $viewRenderer->withViewPath(__DIR__ . '/views');
}
public function index(): ResponseInterface
{
return $this->viewRenderer->render('index', [
'posts' => $this->posts->findAll(),
'success' => $this->flash->get('success'),
]);
}
public function create(ServerRequestInterface $request): ResponseInterface
{
$form = new PostForm();
if ($this->formHydrator->populateFromPostAndValidate($form, $request)) {
$this->posts->create($form->title, $form->body);
$this->flash->set('success', 'Post created!');
return $this->redirectToIndex();
}
return $this->viewRenderer->render('create', ['form' => $form]);
}
public function edit(ServerRequestInterface $request, #[RouteArgument('id')] int $id): ResponseInterface
{
$post = $this->posts->findById($id);
if ($post === null) {
return $this->notFoundHandler->handle($request);
}
$form = new PostForm();
$form->title = $post['title'];
$form->body = $post['body'];
if ($this->formHydrator->populateFromPostAndValidate($form, $request)) {
$this->posts->update($id, $form->title, $form->body);
$this->flash->set('success', 'Post updated!');
return $this->redirectToIndex();
}
return $this->viewRenderer->render('edit', ['form' => $form, 'id' => $id]);
}
public function delete(#[RouteArgument('id')] int $id): ResponseInterface
{
$this->posts->delete($id);
$this->flash->set('success', 'Post deleted.');
return $this->redirectToIndex();
}
private function redirectToIndex(): ResponseInterface
{
return $this->responseFactory
->createResponse(Status::FOUND)
->withHeader(Header::LOCATION, $this->urlGenerator->generate('post/index'));
}
}
O create e o edit tratam GET e POST no mesmo método. O populateFromPostAndValidate() retorna false quando ainda não veio nada no POST ou quando a validação falha, e nos dois casos a gente só renderiza o formulário de novo. Num envio inválido, o formulário já volta com o que o usuário digitou e com os erros.
O #[RouteArgument('id')] pega o {id} da URL e entrega direto no método, já como int. O redirect é uma resposta PSR-7 comum, com status 302 e o header Location.
Passo 8: Rotas
Em config/common/routes.php, adicione um grupo /posts ao lado da rota da home:
<?php
declare(strict_types=1);
use App\Web;
use App\Web\Post\PostController;
use Yiisoft\Http\Method;
use Yiisoft\Router\Group;
use Yiisoft\Router\Route;
return [
Group::create()
->routes(
Route::get('/')
->action(Web\HomePage\Action::class)
->name('home'),
Group::create('/posts')
->namePrefix('post/')
->routes(
Route::get('')
->action([PostController::class, 'index'])
->name('index'),
Route::methods([Method::GET, Method::POST], '/create')
->action([PostController::class, 'create'])
->name('create'),
Route::methods([Method::GET, Method::POST], '/{id:\d+}/edit')
->action([PostController::class, 'edit'])
->name('edit'),
Route::post('/{id:\d+}/delete')
->action([PostController::class, 'delete'])
->name('delete'),
),
),
];
O {id:\d+} só aceita números, então /posts/abc/edit vira 404 antes de chegar no controller. O delete só aceita POST, então um GET nele retorna 405.
Passo 9: Views
As views são PHP puro e ficam em src/Web/Post/views/.
index.php:
<?php
declare(strict_types=1);
use Yiisoft\Html\Html;
/**
* @var Yiisoft\View\WebView $this
* @var Yiisoft\Router\UrlGeneratorInterface $urlGenerator
* @var Yiisoft\Yii\View\Renderer\Csrf $csrf
* @var array[] $posts
* @var string|null $success
*/
$this->setTitle('Posts');
?>
<div class="page-header">
<h1>Posts</h1>
<a class="btn" href="<?= $urlGenerator->generate('post/create') ?>">+ New post</a>
</div>
<?php if ($success): ?>
<div class="alert"><?= Html::encode($success) ?></div>
<?php endif ?>
<?php if ($posts === []): ?>
<p class="empty">No posts yet. Go write the first one!</p>
<?php else: ?>
<table class="table">
<thead>
<tr>
<th>#</th>
<th>Title</th>
<th>Updated</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($posts as $post): ?>
<tr>
<td><?= $post['id'] ?></td>
<td><?= Html::encode($post['title']) ?></td>
<td><?= Html::encode($post['updated_at']) ?></td>
<td class="actions">
<a href="<?= $urlGenerator->generate('post/edit', ['id' => $post['id']]) ?>">Edit</a>
<?= Html::form()
->post($urlGenerator->generate('post/delete', ['id' => $post['id']]))
->csrf($csrf)
->addAttributes(['onsubmit' => "return confirm('Delete this post?')"])
->open() ?>
<button type="submit" class="link-danger">Delete</button>
<?= Html::form()->close() ?>
</td>
</tr>
<?php endforeach ?>
</tbody>
</table>
<?php endif ?>
O $urlGenerator e o $csrf estão disponíveis em toda view, porque a config do template injeta os dois. O Html::encode() está ali para escapar o que o usuário digitou.
_form.php, usado pelo create e pelo edit:
<?php
declare(strict_types=1);
use Yiisoft\FormModel\Field;
use Yiisoft\Html\Html;
/**
* @var Yiisoft\Router\UrlGeneratorInterface $urlGenerator
* @var Yiisoft\Yii\View\Renderer\Csrf $csrf
* @var App\Web\Post\PostForm $form
* @var string $action
* @var string $submitLabel
*/
?>
<?= Html::form()->post($action)->csrf($csrf)->open() ?>
<?= Field::text($form, 'title') ?>
<?= Field::textarea($form, 'body')->addInputAttributes(['rows' => 8]) ?>
<div class="form-actions">
<?= Html::submitButton($submitLabel, ['class' => 'btn']) ?>
<a href="<?= $urlGenerator->generate('post/index') ?>">Cancel</a>
</div>
<?= Html::form()->close() ?>
O Field::text($form, 'title') imprime o label, o input com o valor atual e os erros de validação.
O create.php e o edit.php só definem o título e incluem o formulário:
<?php // create.php
declare(strict_types=1);
/**
* @var Yiisoft\View\WebView $this
* @var Yiisoft\Router\UrlGeneratorInterface $urlGenerator
* @var App\Web\Post\PostForm $form
*/
$this->setTitle('New post');
?>
<h1>New post</h1>
<?= $this->render('_form', [
'form' => $form,
'action' => $urlGenerator->generate('post/create'),
'submitLabel' => 'Create',
]) ?>
<?php // edit.php
declare(strict_types=1);
/**
* @var Yiisoft\View\WebView $this
* @var Yiisoft\Router\UrlGeneratorInterface $urlGenerator
* @var App\Web\Post\PostForm $form
* @var int $id
*/
$this->setTitle('Edit post');
?>
<h1>Edit post #<?= $id ?></h1>
<?= $this->render('_form', [
'form' => $form,
'action' => $urlGenerator->generate('post/edit', ['id' => $id]),
'submitLabel' => 'Save',
]) ?>
O cabeçalho e o rodapé vêm do layout do template, em src/Web/Shared/Layout/Main/layout.php. Para o visual, adicionei algumas regras no fim do assets/main/site.css. Elas estão no repositório, se quiser o mesmo resultado.
Passo 10: Testando
Com o ./yii serve rodando, acesse http://127.0.0.1:8080/posts.
Envie um título curto com o corpo vazio, e as regras do PostForm entram em ação:
A edição reaproveita o mesmo formulário, preenchido com os dados atuais:
O delete pede confirmação antes:
Este é o caminho de uma requisição, do envio do formulário até o redirect:
Se algum formulário retornar 422, está faltando o campo _csrf. Todo formulário POST precisa do ->csrf($csrf).
Próximos passos
Daqui, eu adicionaria paginação com yiisoft/data e yiisoft/data-db, e depois login com yiisoft/user. Trocar para MySQL ou PostgreSQL é só mudar o config/common/di/db.php, e o repository continua igual.
O código está em yurineves92/crud-yii3.
Se você já usou Yii2, fiquei curioso: o que parece mais diferente para você no Yii3?






Top comments (0)