DEV Community

Cover image for Thinking in Yii3: How the Framework Fits Together
Yuri Neves
Yuri Neves

Posted on

Thinking in Yii3: How the Framework Fits Together

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

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.

Your config and package config merged into the container

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,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

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

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:

  1. What does it need? Put that in the constructor.
  2. Is it an interface or a value? Then it needs a definition in config/*/di/.
  3. 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:

Request lifecycle: index.php, middleware layers, router, your code, emitter

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

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

You can put it in the global list, or attach it only to some routes:

Group::create('/posts')
    ->middleware(ResponseTimeMiddleware::class)
    ->routes(/* ... */),
Enter fullscreen mode Exit fullscreen mode

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

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

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

When I add a feature, I usually go in this order:

  1. A migration for the table.
  2. A repository (or service) in src/<Feature>, with no HTTP in it.
  3. A form model, if there's user input.
  4. An action or controller in src/Web/<Feature>.
  5. Routes in config/common/routes.php.
  6. Views next to the controller.
  7. 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)