If you come from Yii2, the first thing you'll look for in Yii3 is Yii::$app. It isn't there. There's no global application object, no base Controller with a dozen magic properties, and no components array.
Once that clicks, the rest makes a lot more sense. This post is the mental model I'd want before writing any code. If you want to go straight to building something, the step-by-step CRUD uses everything explained here.
It's a set of packages, not one big framework
Yii3 isn't one repository. It's a collection of small yiisoft/* packages, and each one does a single job: yiisoft/router, yiisoft/di, yiisoft/db, yiisoft/validator, yiisoft/view and so on.
The yiisoft/app template is what wires them together into a working app. In my CRUD project, after adding the database and form packages, there are 49 yiisoft/* packages in vendor/. You don't have to know most of them, but it helps to know they exist and that each can be used outside Yii.
That also means you add features with composer require, not by enabling something in a big config file. You need a database? composer require yiisoft/db-sqlite. Forms? yiisoft/form-model. And most packages bring their own config, which leads to the next point.
Config is PHP arrays, merged from everywhere
All configuration lives in config/, split into groups. The map of groups is in 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',
// ...
],
di is every file in config/common/di/, di-web is that plus the files in config/web/di/, and so on. The web app and the console app read different groups, and the environments (dev, prod, test) can override params.
The important detail is that packages ship config too. yiisoft/session has a config/di-web.php that maps FlashInterface to Flash. yiisoft/db-migration registers the migrate:* console commands in its config/params.php. When you install a package, yiisoft/config adds its files to the "merge plan" (config/.merge-plan.php), and at runtime they are merged with yours.
So after composer require yiisoft/db-migration, ./yii migrate:up just exists, without any line written by you. If you edit configuration.php itself, run composer yii-config-rebuild to update the plan.
The container builds everything
This is the biggest change from Yii2. In Yii3 you never fetch a service from somewhere: you ask for it in the constructor and the DI container (yiisoft/di, PSR-11) passes it in.
final readonly class PostRepository
{
public function __construct(
private ConnectionInterface $db,
) {}
}
For a concrete class like PostRepository, you don't write any config. The container reads the constructor, builds what's needed and injects it. That's autowiring.
You only need a definition when the container can't guess. That happens with an interface (which implementation?) or a scalar (which DSN?). This is the database connection from the CRUD, in 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,
),
];
Every class in the app depends on ConnectionInterface, never on SQLite. Switching to MySQL means changing this file only.
In practice, there are three questions I ask when writing a class:
- What does it need? Put that in the constructor.
- Is it an interface or a value? Then it needs a definition in
config/*/di/. - Does it need to know about HTTP? If not, keep it out of
src/Web.
One request, start to finish
Here's what happens when a request hits the app:
public/index.php creates an HttpApplicationRunner. The runner builds the config and the container, then hands the request to the application. The application runs a stack of PSR-15 middleware defined in config/web/di/application.php:
'withMiddlewares()' => [
[
ErrorCatcher::class,
SessionMiddleware::class,
CsrfTokenMiddleware::class,
RequestCatcherMiddleware::class,
Router::class,
],
],
Each middleware gets the request, can do something before, calls the next one and can do something with the response after. ErrorCatcher is the outermost layer, so any exception thrown deeper ends up there. The Router is the last one: it finds the route and calls your action. Whatever your action returns travels back out through the same layers, and SapiEmitter sends it to the browser.
Middleware is how you plug into the request
Since everything is a middleware, adding behavior around a request is the same idea everywhere. This one adds a response time header:
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');
}
}
You can put it in the global list, or attach it only to some routes:
Group::create('/posts')
->middleware(ResponseTimeMiddleware::class)
->routes(/* ... */),
Authentication, access checks, CORS and rate limits all work this way. In my blog, the admin area is a route group with an access-check middleware, and the API group has a token middleware.
Actions are just callables
The template doesn't have a base controller. The home page is a single class with __invoke():
final readonly class Action
{
public function __construct(
private WebViewRenderer $viewRenderer,
) {}
public function __invoke(): ResponseInterface
{
return $this->viewRenderer->render(__DIR__ . '/template');
}
}
A route points to it with ->action(Action::class). If you prefer controllers with several methods, ->action([PostController::class, 'index']) works too, and that's what I used in the CRUD. Either way, the action receives services through the constructor and returns a PSR-7 response. No base class needed.
Things are immutable
You'll see a lot of with*() methods in Yii3: $response->withHeader(), $viewRenderer->withViewPath(), $viewRenderer->withLayout(). They don't change the object, they return a new one.
// $viewRenderer is shared by the whole app, so we keep our own copy
$this->viewRenderer = $viewRenderer->withViewPath(__DIR__ . '/views');
The reason is that services are shared. If one controller changed the view path on the shared renderer, every other controller would get it too. With immutability, that can't happen. It's also what lets Yii3 run on long-running servers like RoadRunner (there's yiisoft/yii-runner-roadrunner), where the same objects serve many requests.
How I organize a feature
The template already suggests a layout: src/Web/HomePage/Action.php with template.php next to it. I keep that idea, grouping by feature, and separate what is web from what isn't:
src/
Post/
PostRepository.php # talks to the database, knows nothing about HTTP
Web/
Post/
PostController.php # HTTP: reads the request, returns a response
PostForm.php # what the form accepts + validation rules
views/
index.php
_form.php
Migration/
M261008033620CreatePostTable.php
When I add a feature, I usually go in this order:
- A migration for the table.
- A repository (or service) in
src/<Feature>, with no HTTP in it. - A form model, if there's user input.
- An action or controller in
src/Web/<Feature>. - Routes in
config/common/routes.php. - Views next to the controller.
- A definition in
config/*/di/only if something new is an interface or needs config values.
The repository doesn't know it's being called from a web page. The same class can be used from a console command or an API endpoint later without changes. In my blog, the same PostRepository serves both the site pages and the REST API.
Coming from Yii2
The short version: what was implicit is now explicit. Yii::$app->db becomes a ConnectionInterface in the constructor. components in the config become DI definitions. behaviors() and filters become middleware. Controllers don't extend anything, and $this->render() becomes an injected WebViewRenderer. Active Record isn't the default anymore. It exists as a separate package, but the query builder is enough for a lot of things.
It's more typing at first. In exchange, you can open any class and see everything it depends on.
Next
With this in mind, the CRUD tutorial should read like a sequence of small, obvious steps: a migration, a repository, a form, a controller, routes and views. The code is at yurineves92/crud-yii3.
If something here didn't match your experience with Yii3, tell me in the comments. I'm still learning it too.


Top comments (0)