Yii2 & PHP

How I structure a Yii2 advanced project for client work

#Yii2 #PHP #Deployment

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.