When I start a new Yii2 Advanced project for a client, I follow a structure that has evolved through several years and a number of projects where the initial structure made things harder than they needed to be. Here is what I use now and the reasoning behind the main decisions.
The starting point is not the Yii2 template
Yii2 Advanced gives you a usable skeleton, but it makes some assumptions that do not hold for most real projects. The first thing I do is adjust the structure before writing any application code.
Environment configuration via dotenv
The default Yii2 Advanced setup uses environment-specific config files committed to the repository. That means database passwords end up in version control, and switching between environments requires careful file management.
I replace it with vlucas/phpdotenv:
composer require vlucas/phpdotenv
// common/config/bootstrap.php
$dotenv = DotenvDotenv::createImmutable(dirname(__DIR__, 2));
$dotenv->load();
$dotenv->required(['DB_DSN', 'DB_USERNAME', 'DB_PASSWORD', 'APP_ENV']);
defined('YII_DEBUG') or define('YII_DEBUG', $_ENV['APP_ENV'] === 'development');
defined('YII_ENV') or define('YII_ENV', $_ENV['APP_ENV']);
Config files then read from $_ENV instead of hardcoding values. The .env file is in .gitignore; the .env.example is committed with all keys present but no sensitive values.
Business logic belongs in services, not controllers
The most common mistake in Yii2 projects is fat controllers: hundreds of lines of business logic mixed with request handling. Controllers should do three things — validate input, call a service, return a response.
My common/services/ directory holds the actual logic:
// common/services/PostService.php
class PostService
{
public function publish(Post $post): void
{
if ($post->status === Post::STATUS_PUBLISHED) {
throw new LogicException("Post is already published.");
}
$post->status = Post::STATUS_PUBLISHED;
$post->published_at = $post->published_at ?? time();
$post->reading_time = $this->calculateReadingTime($post->content);
if (!$post->save()) {
throw new RuntimeException("Failed to save post: " . implode(', ', $post->getFirstErrors()));
}
$this->sitemapService->regenerate();
}
private function calculateReadingTime(string $content): int
{
$wordCount = str_word_count(strip_tags($content));
return (int) ceil($wordCount / 200);
}
}
The controller becomes simple:
public function actionPublish(int $id): Response
{
$post = $this->findPost($id);
$this->postService->publish($post);
Yii::$app->session->setFlash('success', 'Post published.');
return $this->redirect(['view', 'id' => $post->id]);
}
Models stay focused on data
Models define the schema, validation rules, relations, and behaviors. That is it. No business logic. If a method on a model would need to call another service or do something with side effects, it belongs in a service class instead.
// Good: data concern, lives in model
public function getIsPublished(): bool
{
return $this->status === self::STATUS_PUBLISHED;
}
// Bad: business concern, should be in a service
public function publish(): void
{
$this->status = self::STATUS_PUBLISHED;
$this->save();
// sending email here is wrong
Yii::$app->mailer->compose()->send();
}
Database migrations for everything
Every change to the database schema goes through a migration. No exceptions. Migrations run in order and are kept in version control, which means any developer (or server) can reproduce the exact database state by running php yii migrate.
I name migrations descriptively:
php yii migrate/create add_reading_time_to_post
php yii migrate/create create_analytics_hit_table
php yii migrate/create add_index_to_post_published_at
The directory structure that results
common/
config/ — shared config, loaded by all apps
models/ — ActiveRecord models
services/ — business logic
components/ — reusable Yii2 components
mail/ — email view templates
frontend/
controllers/ — thin, request-handling only
views/ — PHP templates
assets/ — asset bundles
web/ — public root (css, js, images)
backend/
controllers/ — admin panel
views/ — admin templates
console/
controllers/ — CLI commands
migrations/ — all DB migrations
Why this works for client work specifically
Client projects have a lifecycle that internal projects do not. They get handed off, picked up again months later, sometimes handed to another developer. A predictable structure means whoever touches the project next can understand it without reading the entire codebase first.
The rule I follow: if I cannot find any file in under ten seconds based on what it does, the project is not organized correctly. Services hold logic. Controllers handle requests. Models define data. That clarity costs nothing to maintain and saves hours every time someone needs to make a change.