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