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 taskNo 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 theApplicationobject 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:
- Finds the view file at
views/task/index.php(controller name determines the folder) - Extracts
$datainto local variables (so['tasks' => $tasks]becomes$tasksinside the view) - 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=1The r parameter is the route. task → TaskController, view → actionView.
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.phpWith 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.