Se você vem do Yii2, a primeira coisa que vai procurar no Yii3 é o Yii::$app. Ele não existe. Não tem objeto global da aplicação, não tem Controller base cheio de propriedades mágicas e não tem array de components.
Quando isso encaixa, o resto faz muito mais sentido. Este post é o modelo mental que eu queria ter antes de escrever qualquer código. Se preferir ir direto para a prática, o CRUD passo a passo usa tudo que está aqui.
É um conjunto de pacotes, não um framework monolítico
O Yii3 não é um repositório só. É uma coleção de pacotes yiisoft/* pequenos, e cada um faz uma coisa: yiisoft/router, yiisoft/di, yiisoft/db, yiisoft/validator, yiisoft/view e por aí vai.
Quem junta tudo num app funcionando é o template yiisoft/app. No meu projeto de CRUD, depois de adicionar os pacotes de banco e de formulário, são 49 pacotes yiisoft/* no vendor/. Você não precisa conhecer a maioria, mas ajuda saber que eles existem e que cada um funciona fora do Yii também.
Isso também quer dizer que você adiciona funcionalidades com composer require, e não ativando algo num arquivo de config gigante. Precisa de banco? composer require yiisoft/db-sqlite. Formulários? yiisoft/form-model. E a maioria dos pacotes traz a própria configuração, que é o próximo ponto.
A configuração é array PHP, mesclado de vários lugares
Toda a configuração fica em config/, dividida em grupos. O mapa dos grupos está em config/configuration.php:
'config-plugin' => [ 'params' => 'common/params.php', 'params-web' => ['$params', 'web/params.php'], 'params-console' => ['$params', 'console/params.php'], 'di' => 'common/di/*.php', 'di-web' => ['$di', 'web/di/*.php'], 'di-console' => '$di', 'routes' => 'common/routes.php', 'bootstrap' => 'common/bootstrap.php', // ... ], O grupo di é todo arquivo em config/common/di/, o di-web é isso mais os arquivos de config/web/di/, e assim por diante. O app web e o console leem grupos diferentes, e os ambientes (dev, prod, test) podem sobrescrever os params.
O detalhe importante é que os pacotes também trazem configuração. O yiisoft/session tem um config/di-web.php que liga FlashInterface a Flash. O yiisoft/db-migration registra os comandos migrate:* no config/params.php dele. Quando você instala um pacote, o yiisoft/config adiciona os arquivos dele ao "merge plan" (config/.merge-plan.php), e em tempo de execução eles são mesclados com os seus.
Então, depois de composer require yiisoft/db-migration, o ./yii migrate:up simplesmente existe, sem nenhuma linha escrita por você. Se você mexer no próprio configuration.php, rode composer yii-config-rebuild para atualizar o plano.
O container constrói tudo
Essa é a maior mudança em relação ao Yii2. No Yii3 você nunca busca um serviço em algum lugar: você pede no construtor, e o container de DI (yiisoft/di, PSR-11) entrega.
final readonly class PostRepository { public function __construct( private ConnectionInterface $db, ) {} } Para uma classe concreta como PostRepository, você não escreve configuração nenhuma. O container lê o construtor, monta o que precisa e injeta. Isso é autowiring.
Você só precisa de uma definição quando o container não tem como adivinhar. Isso acontece com uma interface (qual implementação?) ou com um valor escalar (qual DSN?). Esta é a conexão de banco do CRUD, em config/common/di/db.php:
return [ ConnectionInterface::class => static fn (Aliases $aliases, SchemaCache $schemaCache) => new Connection( new Driver('sqlite:' . $aliases->get('@runtime/database.sqlite')), $schemaCache, ), ]; Todas as classes do app dependem de ConnectionInterface, nunca de SQLite. Trocar para MySQL é mudar só esse arquivo.
Na prática, faço três perguntas quando escrevo uma classe:
- Do que ela precisa? Isso vai no construtor.
- É uma interface ou um valor? Então precisa de uma definição em
config/*/di/. - Ela precisa saber de HTTP? Se não, fica fora de
src/Web.
Uma requisição, do começo ao fim
Isto é o que acontece quando uma requisição chega no app:
O public/index.php cria um HttpApplicationRunner. O runner monta a configuração e o container, e entrega a requisição para a aplicação. A aplicação roda uma pilha de middlewares PSR-15 definida em config/web/di/application.php:
'withMiddlewares()' => [ [ ErrorCatcher::class, SessionMiddleware::class, CsrfTokenMiddleware::class, RequestCatcherMiddleware::class, Router::class, ], ], Cada middleware recebe a requisição, pode fazer algo antes, chama o próximo e pode mexer na resposta depois. O ErrorCatcher é a camada mais externa, então qualquer exceção lançada lá dentro termina nele. O Router é o último: ele encontra a rota e chama a sua action. O que a action retorna volta pelas mesmas camadas, e o SapiEmitter envia para o navegador.
Middleware é como você se encaixa na requisição
Como tudo é middleware, adicionar comportamento em volta de uma requisição segue sempre a mesma ideia. Este aqui adiciona um header com o tempo de resposta:
final class ResponseTimeMiddleware implements MiddlewareInterface { public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $start = microtime(true); $response = $handler->handle($request); $ms = round((microtime(true) - $start) * 1000, 1); return $response->withHeader('X-Response-Time', $ms . 'ms'); } } Você pode colocar na lista global ou ligar só em algumas rotas:
Group::create('/posts') ->middleware(ResponseTimeMiddleware::class) ->routes(/* ... */), Autenticação, controle de acesso, CORS e rate limit funcionam todos assim. No meu blog, a área admin é um grupo de rotas com um middleware de permissão, e o grupo da API tem um middleware de token.
Actions são só callables
O template não tem controller base. A home é uma classe só, com __invoke():
final readonly class Action { public function __construct( private WebViewRenderer $viewRenderer, ) {} public function __invoke(): ResponseInterface { return $this->viewRenderer->render(__DIR__ . '/template'); } } A rota aponta para ela com ->action(Action::class). Se você prefere controllers com vários métodos, ->action([PostController::class, 'index']) também funciona, e foi o que usei no CRUD. Nos dois casos, a action recebe os serviços pelo construtor e retorna uma resposta PSR-7. Não precisa de classe base.
As coisas são imutáveis
Você vai ver muito método with*() no Yii3: $response->withHeader(), $viewRenderer->withViewPath(), $viewRenderer->withLayout(). Eles não alteram o objeto, retornam um novo.
// o $viewRenderer é compartilhado pelo app todo, então guardamos uma cópia nossa $this->viewRenderer = $viewRenderer->withViewPath(__DIR__ . '/views'); O motivo é que os serviços são compartilhados. Se um controller mudasse o caminho das views no renderer compartilhado, todos os outros controllers seriam afetados. Com imutabilidade, isso não acontece. É também o que permite rodar o Yii3 em servidores de longa duração como o RoadRunner (existe o yiisoft/yii-runner-roadrunner), onde os mesmos objetos atendem muitas requisições.
Como eu organizo uma funcionalidade
O template já sugere um formato: src/Web/HomePage/Action.php com o template.php ao lado. Eu sigo essa ideia, agrupando por funcionalidade, e separo o que é web do que não é:
src/ Post/ PostRepository.php # fala com o banco, não sabe nada de HTTP Web/ Post/ PostController.php # HTTP: lê a requisição, devolve a resposta PostForm.php # o que o formulário aceita + regras de validação views/ index.php _form.php Migration/ M261008033620CreatePostTable.php Quando adiciono uma funcionalidade, costumo seguir esta ordem:
- Uma migration para a tabela.
- Um repository (ou service) em
src/<Funcionalidade>, sem nada de HTTP. - Um form model, se tiver entrada do usuário.
- Uma action ou controller em
src/Web/<Funcionalidade>. - As rotas em
config/common/routes.php. - As views ao lado do controller.
- Uma definição em
config/*/di/só se algo novo for interface ou precisar de valores de config.
O repository não sabe que está sendo chamado de uma página web. A mesma classe pode ser usada depois num comando de console ou num endpoint de API, sem mudança. No meu blog, o mesmo PostRepository atende tanto as páginas do site quanto a API REST.
Vindo do Yii2
Resumindo: o que era implícito agora é explícito. Yii::$app->db vira um ConnectionInterface no construtor. Os components da config viram definições de DI. behaviors() e filtros viram middleware. Controllers não estendem nada, e o $this->render() vira um WebViewRenderer injetado. Active Record deixou de ser o padrão. Ele existe como pacote separado, mas o query builder resolve muita coisa.
No começo se digita mais. Em troca, você abre qualquer classe e vê tudo de que ela depende.
Próximo passo
Com isso em mente, o tutorial de CRUD deve parecer uma sequência de passos pequenos e óbvios: uma migration, um repository, um formulário, um controller, as rotas e as views. O código está em yurineves92/crud-yii3.
Se algo aqui não bate com a sua experiência com Yii3, me conta nos comentários. Também estou aprendendo.


