Back to blog

MVC in Yii2: Models, Views, Controllers & Routing

yii2phpmvcbackendframework
MVC in Yii2: Models, Views, Controllers & Routing

Phase 1 got you a running Yii2 app. Phase 2 teaches you how to actually build with it.

Yii2 is an MVC framework — every request flows through a Controller, every rendered page goes through a View, and every piece of data lives in a Model. Understanding how these three layers talk to each other, and how routing decides which controller handles which URL, is the foundation of everything else in Yii2.

This post is hands-on. By the end you'll have built a working task manager: list tasks, view one, create, edit, and delete — the full CRUD cycle, routing and all.

What You'll Build

A simple task manager with:

GET  /task             → list all tasks
GET  /task/view?id=1   → view one task
GET  /task/create      → show create form
POST /task/create      → handle form submit
GET  /task/update?id=1 → show edit form
POST /task/update?id=1 → handle edit submit
POST /task/delete?id=1 → delete a task

No database yet (that's Phase 3) — we'll use an in-memory array to keep the focus on MVC mechanics.


The MVC Request Lifecycle in Yii2

Every request in Yii2 flows through the same path:

Key points:

  • The entry script (web/index.php) creates the Application object and runs it
  • The URL Manager parses the URL into controller/action + parameters
  • Yii2 instantiates the Controller and calls the matching action*() method
  • The action fetches data (from a Model), then renders a View
  • The View returns HTML, the Controller sends it as the response

Controllers

A Controller in Yii2 is a class that extends yii\web\Controller. Every public method named actionSomething() automatically maps to the URL segment something.

Your First Controller

Create controllers/TaskController.php:

<?php
 
namespace app\controllers;
 
use yii\web\Controller;
use yii\web\NotFoundHttpException;
 
class TaskController extends Controller
{
    // In-memory "database" — replace with Active Record in Phase 3
    private static array $tasks = [
        1 => ['id' => 1, 'title' => 'Buy groceries', 'done' => false],
        2 => ['id' => 2, 'title' => 'Write Phase 2 post', 'done' => true],
        3 => ['id' => 3, 'title' => 'Review pull requests', 'done' => false],
    ];
    private static int $nextId = 4;
 
    // GET /task  (Yii2 maps "index" to the default action)
    public function actionIndex(): string
    {
        return $this->render('index', [
            'tasks' => self::$tasks,
        ]);
    }
 
    // GET /task/view?id=1
    public function actionView(int $id): string
    {
        $task = self::$tasks[$id] ?? null;
        if ($task === null) {
            throw new NotFoundHttpException("Task #$id not found.");
        }
        return $this->render('view', ['task' => $task]);
    }
 
    // GET /task/create  → show form
    // POST /task/create → process form
    public function actionCreate(): string|\yii\web\Response
    {
        if (\Yii::$app->request->isPost) {
            $title = \Yii::$app->request->post('title', '');
            if (trim($title) !== '') {
                $id = self::$nextId++;
                self::$tasks[$id] = ['id' => $id, 'title' => $title, 'done' => false];
                return $this->redirect(['task/view', 'id' => $id]);
            }
        }
        return $this->render('create');
    }
 
    // GET/POST /task/update?id=1
    public function actionUpdate(int $id): string|\yii\web\Response
    {
        $task = self::$tasks[$id] ?? null;
        if ($task === null) {
            throw new NotFoundHttpException("Task #$id not found.");
        }
 
        if (\Yii::$app->request->isPost) {
            $task['title'] = \Yii::$app->request->post('title', $task['title']);
            $task['done']  = (bool)\Yii::$app->request->post('done', false);
            self::$tasks[$id] = $task;
            return $this->redirect(['task/index']);
        }
 
        return $this->render('update', ['task' => $task]);
    }
 
    // POST /task/delete?id=1
    public function actionDelete(int $id): \yii\web\Response
    {
        unset(self::$tasks[$id]);
        return $this->redirect(['task/index']);
    }
}

What $this->render() Does

$this->render('index', $data) does three things:

  1. Finds the view file at views/task/index.php (controller name determines the folder)
  2. Extracts $data into local variables (so ['tasks' => $tasks] becomes $tasks inside the view)
  3. Wraps the view output in the layout (by default views/layouts/main.php)

To render without a layout (for AJAX responses):

return $this->renderPartial('_form', ['task' => $task]);
// or
return $this->renderAjax('_form', ['task' => $task]);

The $defaultAction Property

By default, actionIndex() runs when no action is specified in the URL. Override it:

class TaskController extends Controller
{
    public $defaultAction = 'list';   // now GET /task → actionList()
}

Filters and behaviors()

Controllers declare filters (access control, rate limiting, logging) via behaviors():

public function behaviors(): array
{
    return [
        'access' => [
            'class' => \yii\filters\AccessControl::class,
            'rules' => [
                ['allow' => true, 'roles' => ['@']],       // logged-in only
                ['allow' => true, 'actions' => ['index', 'view'], 'roles' => ['?']], // guests can read
            ],
        ],
        'verbs' => [
            'class' => \yii\filters\VerbFilter::class,
            'actions' => [
                'delete' => ['POST'],   // only POST allowed for delete
            ],
        ],
    ];
}

Views

Views in Yii2 are plain PHP files that output HTML. They live in views/<controller-name>/.

Create the Views

Create the views/task/ directory and these files:

views/task/index.php — task list:

<?php
/** @var yii\web\View $this */
/** @var array[] $tasks */
 
use yii\helpers\Html;
 
$this->title = 'Tasks';
?>
<div class="task-index">
    <h1><?= Html::encode($this->title) ?></h1>
 
    <p><?= Html::a('Create Task', ['task/create'], ['class' => 'btn btn-success']) ?></p>
 
    <table class="table table-bordered">
        <thead>
            <tr>
                <th>#</th>
                <th>Title</th>
                <th>Status</th>
                <th>Actions</th>
            </tr>
        </thead>
        <tbody>
        <?php foreach ($tasks as $task): ?>
            <tr>
                <td><?= $task['id'] ?></td>
                <td><?= Html::encode($task['title']) ?></td>
                <td><?= $task['done'] ? '✅ Done' : '⏳ Pending' ?></td>
                <td>
                    <?= Html::a('View', ['task/view', 'id' => $task['id']]) ?>
                    <?= Html::a('Edit', ['task/update', 'id' => $task['id']]) ?>
                    <?= Html::a('Delete', ['task/delete', 'id' => $task['id']], [
                        'data-method' => 'post',
                        'data-confirm' => 'Are you sure?',
                    ]) ?>
                </td>
            </tr>
        <?php endforeach; ?>
        </tbody>
    </table>
</div>

views/task/view.php — single task:

<?php
/** @var yii\web\View $this */
/** @var array $task */
 
use yii\helpers\Html;
 
$this->title = $task['title'];
?>
<div class="task-view">
    <h1><?= Html::encode($this->title) ?></h1>
 
    <p><strong>Status:</strong> <?= $task['done'] ? 'Done' : 'Pending' ?></p>
 
    <p>
        <?= Html::a('Edit', ['task/update', 'id' => $task['id']], ['class' => 'btn btn-primary']) ?>
        <?= Html::a('Back to list', ['task/index'], ['class' => 'btn btn-default']) ?>
    </p>
</div>

views/task/create.php — create form:

<?php
/** @var yii\web\View $this */
 
use yii\helpers\Html;
 
$this->title = 'Create Task';
?>
<div class="task-create">
    <h1><?= Html::encode($this->title) ?></h1>
 
    <form method="post" action="">
        <?= Html::hiddenInput(\Yii::$app->request->csrfParam, \Yii::$app->request->csrfToken) ?>
        <div class="form-group">
            <label for="title">Task Title</label>
            <input type="text" id="title" name="title" class="form-control" required>
        </div>
        <button type="submit" class="btn btn-success">Create</button>
        <?= Html::a('Cancel', ['task/index']) ?>
    </form>
</div>

views/task/update.php — edit form:

<?php
/** @var yii\web\View $this */
/** @var array $task */
 
use yii\helpers\Html;
 
$this->title = 'Update: ' . $task['title'];
?>
<div class="task-update">
    <h1><?= Html::encode($this->title) ?></h1>
 
    <form method="post" action="">
        <?= Html::hiddenInput(\Yii::$app->request->csrfParam, \Yii::$app->request->csrfToken) ?>
        <div class="form-group">
            <label for="title">Task Title</label>
            <input type="text" id="title" name="title" class="form-control"
                   value="<?= Html::encode($task['title']) ?>" required>
        </div>
        <div class="form-check">
            <input type="checkbox" id="done" name="done" value="1" class="form-check-input"
                   <?= $task['done'] ? 'checked' : '' ?>>
            <label class="form-check-label" for="done">Mark as done</label>
        </div>
        <br>
        <button type="submit" class="btn btn-primary">Save</button>
        <?= Html::a('Cancel', ['task/index']) ?>
    </form>
</div>

Why Html::encode()?

Never output user-supplied data directly. Html::encode() converts <, >, ", ', & to their HTML entities, preventing XSS. This is one of the most important habits when working with any templating system.

// UNSAFE — XSS vulnerability:
echo $task['title'];
 
// SAFE:
echo Html::encode($task['title']);

The Layout

Views render inside a layout. The default layout is views/layouts/main.php. It wraps every page with the common header, navigation, and footer. The view content replaces $content:

<?php
/** @var yii\web\View $this */
/** @var string $content */
 
use app\assets\AppAsset;
AppAsset::register($this);
?>
<?php $this->beginPage() ?>
<!DOCTYPE html>
<html lang="<?= Yii::$app->language ?>">
<head>
    <meta charset="<?= Yii::$app->charset ?>">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title><?= Html::encode($this->title) ?></title>
    <?php $this->head() ?>
</head>
<body>
<?php $this->beginBody() ?>
 
<nav class="navbar navbar-default navbar-fixed-top"><!-- nav here --></nav>
<div class="container"><?= $content ?></div>
<footer><!-- footer here --></footer>
 
<?php $this->endBody() ?>
</body>
</html>
<?php $this->endPage() ?>

To use a different layout for a controller:

class TaskController extends Controller
{
    public $layout = 'minimal';   // uses views/layouts/minimal.php
}

Or disable the layout for one action:

public function actionApi(): string
{
    $this->layout = false;
    return $this->render('api-response');
}

Partial Views and $this->render() in Views

Reusable snippets go into files prefixed with _ (convention, not required):

// views/task/_form.php — shared form partial
<?php
/** @var array $task */
/** @var string $action */
use yii\helpers\Html;
?>
<form method="post" action="<?= $action ?>">
    <?= Html::hiddenInput(\Yii::$app->request->csrfParam, \Yii::$app->request->csrfToken) ?>
    <input type="text" name="title" value="<?= Html::encode($task['title'] ?? '') ?>">
    <button type="submit">Save</button>
</form>

Render a partial from another view:

// In create.php or update.php:
<?= $this->render('_form', ['task' => $task ?? [], 'action' => '']) ?>

Models

Models in Yii2 represent data and business rules. For form data and validation (without a database), extend yii\base\Model. For database-backed data, extend yii\db\ActiveRecord (covered in Phase 3).

Creating a Model for Validation

Let's replace the raw request->post() calls in our controller with a proper Model:

Create models/TaskForm.php:

<?php
 
namespace app\models;
 
use yii\base\Model;
 
class TaskForm extends Model
{
    public string $title = '';
    public bool $done = false;
 
    public function rules(): array
    {
        return [
            // title is required, string, max 200 chars
            [['title'], 'required'],
            [['title'], 'string', 'max' => 200],
            [['title'], 'trim'],   // strip leading/trailing whitespace
 
            // done is a boolean
            [['done'], 'boolean'],
        ];
    }
 
    public function attributeLabels(): array
    {
        return [
            'title' => 'Task Title',
            'done'  => 'Completed',
        ];
    }
}

Using the Model in the Controller

Update actionCreate() to use the model:

public function actionCreate(): string|\yii\web\Response
{
    $model = new \app\models\TaskForm();
 
    if ($model->load(\Yii::$app->request->post()) && $model->validate()) {
        // model is valid — save it
        $id = self::$nextId++;
        self::$tasks[$id] = [
            'id'    => $id,
            'title' => $model->title,
            'done'  => $model->done,
        ];
        return $this->redirect(['task/view', 'id' => $id]);
    }
 
    // GET request or validation failed — show form
    return $this->render('create', ['model' => $model]);
}

$model->load($post) reads POST data and maps it to the model's attributes based on the model class name (it looks for $_POST['TaskForm']['title']). $model->validate() runs the rules() and populates $model->errors if validation fails.

Displaying Validation Errors in the View

Update views/task/create.php to use the model:

<?php
/** @var yii\web\View $this */
/** @var app\models\TaskForm $model */
 
use yii\helpers\Html;
use yii\widgets\ActiveForm;
 
$this->title = 'Create Task';
?>
<div class="task-create">
    <h1><?= Html::encode($this->title) ?></h1>
 
    <?php $form = ActiveForm::begin(); ?>
 
        <?= $form->field($model, 'title')->textInput(['placeholder' => 'What needs to be done?']) ?>
        <?= $form->field($model, 'done')->checkbox() ?>
 
        <div class="form-group">
            <?= Html::submitButton('Create', ['class' => 'btn btn-success']) ?>
        </div>
 
    <?php ActiveForm::end(); ?>
</div>

ActiveForm is a Yii2 widget that:

  • Generates the correct <form> tag with CSRF token
  • Renders field labels, inputs, and validation error messages automatically
  • Connects to client-side validation (shows errors on blur without a page reload)

URL Routing

Yii2's URL routing is handled by the URL Manager component. It maps URLs to controller/action routes and generates URLs in your code.

Default URL Format

Without any configuration, Yii2 uses query-string URLs:

/index.php?r=task/view&id=1

The r parameter is the route. taskTaskController, viewactionView.

Pretty URLs

Enable clean URLs by configuring urlManager in config/web.php:

'components' => [
    'urlManager' => [
        'enablePrettyUrl' => true,
        'showScriptName'  => false,   // hide index.php from URL
        'rules'           => [],       // custom rules go here
    ],
],

You also need .htaccess (Apache) or an nginx rewrite rule to route all requests through index.php:

# web/.htaccess
RewriteEngine on
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . index.php

With pretty URLs enabled, the default route format becomes:

/task          → TaskController::actionIndex()
/task/view/1   → TaskController::actionView(1)   ← id as path segment
/task/create   → TaskController::actionCreate()

Wait — how does /task/view/1 map the 1 to $id? Without a custom rule, extra path segments are treated as GET parameters. You'd still use /task/view?id=1. To get /task/view/1, add a URL rule.

URL Rules

URL rules define custom patterns. Add them to the rules array in urlManager:

'urlManager' => [
    'enablePrettyUrl' => true,
    'showScriptName'  => false,
    'rules' => [
        // Pattern → Route
        'tasks'                => 'task/index',
        'tasks/create'         => 'task/create',
        'tasks/<id:\d+>'       => 'task/view',
        'tasks/<id:\d+>/edit'  => 'task/update',
        'tasks/<id:\d+>/delete' => 'task/delete',
    ],
],

Now:

GET  /tasks           → TaskController::actionIndex()
GET  /tasks/create    → TaskController::actionCreate()
GET  /tasks/42        → TaskController::actionView($id=42)
GET  /tasks/42/edit   → TaskController::actionUpdate($id=42)
POST /tasks/42/delete → TaskController::actionDelete($id=42)

The <id:\d+> syntax is a named pattern: id is the parameter name, \d+ is the regex. Yii2 passes the matched value to the action method automatically.

Generating URLs in Code

Never hardcode URLs. Use Yii2's URL helper:

use yii\helpers\Url;
 
// In a controller or model:
$url = Url::to(['task/view', 'id' => 42]);   // → /tasks/42
$url = Url::to(['task/index']);               // → /tasks
$url = Url::toRoute(['task/create']);         // same, alternative API
 
// Absolute URL (includes http://hostname):
$url = Url::to(['task/view', 'id' => 42], true);
 
// In a view:
$this->redirect(['task/index']);              // redirect by route array
 
// Html::a() accepts route arrays directly:
Html::a('View', ['task/view', 'id' => $task->id])

When you change a URL rule, all Url::to() calls automatically produce the new URL. No find-and-replace across views.

URL Rules for REST-style Routes

'rules' => [
    // GET /api/tasks        → api/task/index
    // POST /api/tasks       → api/task/create
    // GET /api/tasks/42     → api/task/view
    // PUT /api/tasks/42     → api/task/update
    // DELETE /api/tasks/42  → api/task/delete
    [
        'class'       => 'yii\rest\UrlRule',
        'controller'  => 'api/task',
        'pluralize'   => false,
    ],
],

Putting It Together: The Full Flow

Let's trace a complete create-task request:


Common Controller Patterns

Flash Messages

Flash messages survive one redirect — perfect for success/error feedback:

// In the controller after a successful save:
\Yii::$app->session->setFlash('success', 'Task created successfully!');
return $this->redirect(['task/index']);
 
// In the view (or layout):
foreach (\Yii::$app->session->getAllFlashes() as $type => $message) {
    echo "<div class='alert alert-{$type}'>" . Html::encode($message) . "</div>";
}

Restricting HTTP Methods

// In behaviors():
'verbs' => [
    'class' => \yii\filters\VerbFilter::class,
    'actions' => [
        'create' => ['GET', 'POST'],
        'update' => ['GET', 'POST'],
        'delete' => ['POST'],
    ],
],

JSON Responses

public function actionApiList(): \yii\web\Response
{
    \Yii::$app->response->format = \yii\web\Response::FORMAT_JSON;
    return $this->asJson(array_values(self::$tasks));
}

Directory Structure After Phase 2

your-app/
├── controllers/
│   ├── SiteController.php   (default, from template)
│   └── TaskController.php   ← new
├── models/
│   └── TaskForm.php         ← new
├── views/
│   ├── layouts/
│   │   └── main.php         (unchanged)
│   ├── site/                (default views from template)
│   └── task/                ← new
│       ├── index.php
│       ├── view.php
│       ├── create.php
│       └── update.php
└── config/
    └── web.php              (updated urlManager)

What You've Learned

✅ How the Yii2 request lifecycle flows from URL to response
✅ How to write controllers and action methods
✅ How views find their template files and merge into layouts
✅ Why Html::encode() is non-negotiable for security
✅ How to use ActiveForm with a Model for validated form handling
✅ How urlManager turns queries into pretty URLs
✅ How to generate URLs with Url::to() — never hardcode them
✅ How to use flash messages, verb filters, and JSON responses


What's Next

The task manager works, but tasks disappear on every request because they live in a static array. Phase 3 connects this to a real database using Yii2's Active Record ORM and Query Builder — the Task model becomes a full database-backed class with Task::find(), $task->save(), and relational queries.


Series navigation:
Post 2: Getting Started with Yii2
Post 4: Database & Active Record

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.