DEV Community

Cover image for Seu primeiro CRUD com Yii3: passo a passo para iniciantes
Yuri Neves
Yuri Neves

Posted on

Seu primeiro CRUD com Yii3: passo a passo para iniciantes

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.

O app pronto: lista de posts com as ações de editar e apagar

Passo 1: Criar o projeto

composer create-project yiisoft/app crud-app
cd crud-app
cp .env.example .env
./yii serve
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
    ),
];
Enter fullscreen mode Exit fullscreen mode

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'],
    ],
];
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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');
    }
}
Enter fullscreen mode Exit fullscreen mode

Rode a migration:

./yii migrate:up
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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 = '';
}
Enter fullscreen mode Exit fullscreen mode

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'));
    }
}
Enter fullscreen mode Exit fullscreen mode

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'),
                ),
        ),
];
Enter fullscreen mode Exit fullscreen mode

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 ?>
Enter fullscreen mode Exit fullscreen mode

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() ?>
Enter fullscreen mode Exit fullscreen mode

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',
]) ?>
Enter fullscreen mode Exit fullscreen mode
<?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',
]) ?>
Enter fullscreen mode Exit fullscreen mode

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.

Lista vazia com o botão

Envie um título curto com o corpo vazio, e as regras do PostForm entram em ação:

Erros de validação embaixo de cada campo

A edição reaproveita o mesmo formulário, preenchido com os dados atuais:

Formulário de edição preenchido com o post atual

O delete pede confirmação antes:

Lista depois de apagar um post, com a mensagem

Este é o caminho de uma requisição, do envio do formulário até o redirect:

Fluxo da requisição: index.php, middleware, router, controller, formulário, repository, SQLite, 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)