Yii2 Forms, Validation & Data Input

Phase 4 locked down your task manager with authentication and RBAC. But every interaction with users starts with a form — registration, login, task creation, profile updates. Get forms wrong and you get garbage data, security holes, and frustrated users.
Yii2's form system is one of its strongest features. ActiveForm generates HTML, wires up client-side validation, handles AJAX validation, displays errors — all from the same rules() you already wrote on your models. One source of truth for validation logic, zero duplication between server and client.
By the end of this post, you'll build forms that validate on the client and server, handle file uploads, save multiple models at once with tabular input, and adapt validation rules per context using scenarios.
What You'll Build
✅ ActiveForm widget with automatic client-side validation
✅ All major built-in validators (required, string, email, unique, compare, etc.)
✅ Custom inline validators and standalone Validator classes
✅ Model scenarios for context-dependent validation
✅ File and image upload with validation
✅ Tabular input — saving multiple models in one form
✅ AJAX validation for real-time server-side checks
1. ActiveForm Fundamentals
How ActiveForm Works
ActiveForm is a widget that generates <form> tags, input fields, labels, error messages, and client-side validation — all driven by your model's rules(). When you call $form->field($model, 'attribute'), Yii2:
- Reads the model's
rules()to determine which validators apply - Generates HTML input with proper
name,id, andvalue - Adds
ariaattributes for accessibility - Attaches JavaScript validation handlers
- Displays the attribute's label from
attributeLabels() - Reserves space for error messages
Basic Form Structure
<?php
// views/task/create.php
use yii\helpers\Html;
use yii\widgets\ActiveForm;
$this->title = 'Create Task';
?>
<h1><?= Html::encode($this->title) ?></h1>
<?php $form = ActiveForm::begin([
'id' => 'task-form',
'options' => ['class' => 'form-horizontal'],
]) ?>
<?= $form->field($model, 'title') ?>
<?= $form->field($model, 'description')->textarea(['rows' => 4]) ?>
<?= $form->field($model, 'status')->dropDownList([
1 => 'To Do',
2 => 'In Progress',
3 => 'Done',
], ['prompt' => '-- Select Status --']) ?>
<?= $form->field($model, 'due_date')->input('date') ?>
<div class="form-group">
<?= Html::submitButton('Create', ['class' => 'btn btn-primary']) ?>
</div>
<?php ActiveForm::end() ?>What Gets Generated
That simple code generates:
<form id="task-form" class="form-horizontal" action="/task/create" method="post">
<input type="hidden" name="_csrf" value="abc123...">
<div class="form-group field-task-title required">
<label class="control-label" for="task-title">Title</label>
<input type="text" id="task-title" class="form-control"
name="Task[title]" aria-required="true">
<div class="help-block"></div>
</div>
<!-- ... more fields ... -->
</form>Key things to notice:
- CSRF token is automatically included
name="Task[title]"— the model class name becomes the array key (this is why$model->load(Yii::$app->request->post())works)requiredCSS class added automatically fromrules()aria-requiredfor accessibilityhelp-blockdiv reserved for error messages
The Controller Side
// controllers/TaskController.php
public function actionCreate()
{
$model = new Task();
if ($model->load(Yii::$app->request->post()) && $model->save()) {
Yii::$app->session->setFlash('success', 'Task created.');
return $this->redirect(['view', 'id' => $model->id]);
}
return $this->render('create', ['model' => $model]);
}The pattern is always the same:
- Create model
- Try to
load()POST data andsave()(which callsvalidate()internally) - If validation fails, re-render the form — errors are already attached to the model
2. Built-in Validators
Yii2 ships with ~25 built-in validators. Here are the ones you'll use most, organized by category.
Required & Type Validators
public function rules()
{
return [
// Required — field must not be empty
[['title', 'email'], 'required'],
[['title'], 'required', 'message' => 'Please enter a title.'],
// String — length constraints
[['title'], 'string', 'max' => 255],
[['description'], 'string', 'max' => 5000],
[['username'], 'string', 'min' => 3, 'max' => 32],
[['code'], 'string', 'length' => 6], // exact length
// Integer
[['status', 'priority'], 'integer'],
[['age'], 'integer', 'min' => 0, 'max' => 150],
// Number (float/decimal)
[['price'], 'number', 'min' => 0],
[['latitude'], 'number', 'min' => -90, 'max' => 90],
// Boolean
[['is_active', 'agree_terms'], 'boolean'],
];
}Format Validators
public function rules()
{
return [
// Email
[['email'], 'email'],
[['email'], 'email', 'checkDNS' => true], // verify domain has MX record
// URL
[['website'], 'url', 'defaultScheme' => 'https'],
// IP address
[['server_ip'], 'ip'],
[['server_ip'], 'ip', 'ipv4' => true, 'ipv6' => false],
// Date
[['birth_date'], 'date', 'format' => 'php:Y-m-d'],
[['start_time'], 'datetime', 'format' => 'php:Y-m-d H:i:s'],
// Regular expression
[['phone'], 'match', 'pattern' => '/^\+?[0-9]{10,15}$/'],
[['slug'], 'match', 'pattern' => '/^[a-z0-9-]+$/'],
];
}Comparison & Uniqueness Validators
public function rules()
{
return [
// Compare two attributes
[['password_repeat'], 'compare', 'compareAttribute' => 'password'],
[['end_date'], 'compare', 'compareAttribute' => 'start_date',
'operator' => '>=', 'type' => 'datetime'],
// Unique in database
[['email'], 'unique'],
[['email'], 'unique', 'targetClass' => User::class,
'message' => 'This email is already taken.'],
// Composite unique (combination must be unique)
[['user_id'], 'unique',
'targetAttribute' => ['user_id', 'project_id'],
'message' => 'User already assigned to this project.'],
// Exist — foreign key must exist in another table
[['category_id'], 'exist',
'targetClass' => Category::class,
'targetAttribute' => 'id'],
];
}Filter Validators
These modify the value rather than rejecting it:
public function rules()
{
return [
// Trim whitespace
[['title', 'email'], 'trim'],
// Set default value
[['status'], 'default', 'value' => self::STATUS_DRAFT],
[['created_at'], 'default',
'value' => function () { return date('Y-m-d H:i:s'); }],
// Custom filter
[['email'], 'filter', 'filter' => 'strtolower'],
[['tags'], 'filter', 'filter' => function ($value) {
return array_map('trim', explode(',', $value));
}],
];
}The safe Validator
safe doesn't validate — it marks attributes as safe for mass assignment:
public function rules()
{
return [
// These attributes can be set via load()
[['notes', 'metadata'], 'safe'],
];
}Warning: Only use
safefor attributes that genuinely don't need validation. If an attribute has any constraint, use a real validator instead.
Validator Quick Reference
| Validator | Purpose | Key Options |
|---|---|---|
required | Must not be empty | when, message |
string | Length constraints | min, max, length |
integer | Must be integer | min, max |
number | Must be numeric | min, max |
boolean | Must be 0/1 | trueValue, falseValue |
email | Valid email format | checkDNS |
url | Valid URL | defaultScheme |
match | Regex pattern | pattern, not |
compare | Compare to another field | compareAttribute, operator |
unique | Unique in DB table | targetClass, targetAttribute |
exist | Must exist in DB | targetClass, targetAttribute |
date | Valid date format | format, min, max |
file | File upload validation | extensions, maxSize |
image | Image file validation | minWidth, maxWidth |
in | Value in allowed list | range, strict |
trim | Strip whitespace | — |
default | Set default value | value |
filter | Transform value | filter |
safe | Allow mass assignment | — |
3. Custom Validators
When built-in validators aren't enough, Yii2 gives you two options: inline validators for one-off rules and standalone validator classes for reusable logic.
Inline Validators
An inline validator is a method on your model:
class Task extends ActiveRecord
{
public function rules()
{
return [
[['due_date'], 'validateFutureDate'],
[['title'], 'validateNoProfanity'],
];
}
/**
* Due date must be in the future (for new tasks only).
*/
public function validateFutureDate($attribute, $params)
{
if (!$this->hasErrors()) {
if ($this->$attribute && strtotime($this->$attribute) < time()) {
$this->addError($attribute, 'Due date must be in the future.');
}
}
}
/**
* Block profanity in task titles.
*/
public function validateNoProfanity($attribute, $params)
{
$blocked = ['badword1', 'badword2'];
foreach ($blocked as $word) {
if (stripos($this->$attribute, $word) !== false) {
$this->addError($attribute, 'Title contains inappropriate language.');
return;
}
}
}
}Key points:
- Method signature:
function validateXxx($attribute, $params) - Use
$this->$attributeto access the value - Call
$this->addError($attribute, $message)to mark as invalid - Check
$this->hasErrors()first to skip if other validators already failed
Inline Validator with when (Conditional Validation)
public function rules()
{
return [
// Only require reason when status is 'rejected'
[['rejection_reason'], 'required', 'when' => function ($model) {
return $model->status === self::STATUS_REJECTED;
}, 'whenClient' => "function (attribute, value) {
return $('#task-status').val() == '3';
}"],
];
}The when callback runs on the server. whenClient is a JavaScript function for client-side validation — it must be a string of JS code.
Standalone Validator Class
For reusable validation logic, create a standalone validator:
// validators/PhoneNumberValidator.php
namespace app\validators;
use yii\validators\Validator;
class PhoneNumberValidator extends Validator
{
public $country = 'US';
private $patterns = [
'US' => '/^(\+1)?[2-9]\d{2}[2-9]\d{6}$/',
'VN' => '/^(\+84|0)(3|5|7|8|9)\d{8}$/',
'UK' => '/^(\+44|0)7\d{9}$/',
];
public function init()
{
parent::init();
if ($this->message === null) {
$this->message = 'Invalid phone number for {country}.';
}
}
protected function validateValue($value)
{
$pattern = $this->patterns[$this->country] ?? $this->patterns['US'];
if (!preg_match($pattern, $value)) {
return [$this->message, ['country' => $this->country]];
}
return null; // valid
}
}Use it in your model:
use app\validators\PhoneNumberValidator;
public function rules()
{
return [
[['phone'], PhoneNumberValidator::class, 'country' => 'VN'],
];
}Standalone Validator with validateAttribute
When you need access to the model (not just the value), override validateAttribute:
// validators/DateRangeValidator.php
namespace app\validators;
use yii\validators\Validator;
class DateRangeValidator extends Validator
{
public $startAttribute;
public $maxDays = 30;
public function validateAttribute($model, $attribute)
{
$start = strtotime($model->{$this->startAttribute});
$end = strtotime($model->$attribute);
if ($end < $start) {
$this->addError($model, $attribute, 'End date must be after start date.');
}
$days = ($end - $start) / 86400;
if ($days > $this->maxDays) {
$this->addError($model, $attribute,
"Date range cannot exceed {$this->maxDays} days.");
}
}
}// In your model:
public function rules()
{
return [
[['end_date'], DateRangeValidator::class,
'startAttribute' => 'start_date',
'maxDays' => 90],
];
}4. Model Scenarios
A scenario lets you apply different validation rules to the same model depending on context. A User model needs different rules for registration vs profile update vs admin edit.
Defining Scenarios
class User extends ActiveRecord
{
const SCENARIO_REGISTER = 'register';
const SCENARIO_UPDATE_PROFILE = 'update-profile';
const SCENARIO_ADMIN_EDIT = 'admin-edit';
public $password_repeat;
public function rules()
{
return [
// Applied in ALL scenarios (no 'on' specified)
[['username', 'email'], 'required'],
[['username'], 'string', 'min' => 3, 'max' => 32],
[['email'], 'email'],
[['email'], 'unique'],
// Only during registration
[['password', 'password_repeat'], 'required',
'on' => self::SCENARIO_REGISTER],
[['password'], 'string', 'min' => 8,
'on' => self::SCENARIO_REGISTER],
[['password_repeat'], 'compare', 'compareAttribute' => 'password',
'on' => self::SCENARIO_REGISTER],
// During profile update — password is optional
[['password'], 'string', 'min' => 8,
'on' => self::SCENARIO_UPDATE_PROFILE],
// Admin can set role and status
[['role', 'status'], 'required',
'on' => self::SCENARIO_ADMIN_EDIT],
[['role'], 'in', 'range' => ['user', 'editor', 'admin'],
'on' => self::SCENARIO_ADMIN_EDIT],
];
}
}Using Scenarios in Controllers
// Registration
public function actionRegister()
{
$model = new User();
$model->scenario = User::SCENARIO_REGISTER;
if ($model->load(Yii::$app->request->post()) && $model->validate()) {
$model->setPassword($model->password);
$model->save(false); // skip validation (already done)
return $this->redirect(['login']);
}
return $this->render('register', ['model' => $model]);
}
// Profile update
public function actionUpdateProfile()
{
$model = Yii::$app->user->identity;
$model->scenario = User::SCENARIO_UPDATE_PROFILE;
if ($model->load(Yii::$app->request->post()) && $model->save()) {
Yii::$app->session->setFlash('success', 'Profile updated.');
return $this->redirect(['profile']);
}
return $this->render('update-profile', ['model' => $model]);
}
// Admin editing a user
public function actionAdminEdit($id)
{
$model = User::findOne($id);
$model->scenario = User::SCENARIO_ADMIN_EDIT;
if ($model->load(Yii::$app->request->post()) && $model->save()) {
return $this->redirect(['admin/users']);
}
return $this->render('admin-edit', ['model' => $model]);
}Scenarios and Safe Attributes
A critical detail: only attributes that appear in active rules for the current scenario are safe for mass assignment. This means load() will silently ignore attributes that don't have rules in the current scenario.
// In SCENARIO_REGISTER, these attributes are safe:
// username, email, password, password_repeat
// In SCENARIO_UPDATE_PROFILE, these attributes are safe:
// username, email, password
// In SCENARIO_ADMIN_EDIT, these attributes are safe:
// username, email, role, statusThis is a security feature — users can't inject values for fields they shouldn't be changing.
Override scenarios() for Fine Control
If you need explicit control over which attributes are safe per scenario:
public function scenarios()
{
$scenarios = parent::scenarios();
$scenarios[self::SCENARIO_REGISTER] = [
'username', 'email', 'password', 'password_repeat',
];
$scenarios[self::SCENARIO_UPDATE_PROFILE] = [
'username', 'email', 'bio', 'avatar',
'!status', // prefix with ! = not safe (can't be mass-assigned)
];
return $scenarios;
}5. Advanced ActiveForm Features
Field Types
ActiveForm::field() returns an ActiveField object with fluent methods for customizing rendering:
<?php $form = ActiveForm::begin() ?>
<!-- Text input (default) -->
<?= $form->field($model, 'title') ?>
<!-- Textarea -->
<?= $form->field($model, 'description')->textarea(['rows' => 6]) ?>
<!-- Password -->
<?= $form->field($model, 'password')->passwordInput() ?>
<!-- Dropdown -->
<?= $form->field($model, 'category_id')->dropDownList(
Category::find()
->select(['name', 'id'])
->indexBy('id')
->column(),
['prompt' => '-- Select Category --']
) ?>
<!-- Radio list -->
<?= $form->field($model, 'priority')->radioList([
1 => 'Low',
2 => 'Medium',
3 => 'High',
]) ?>
<!-- Checkbox list (for many-to-many) -->
<?= $form->field($model, 'tag_ids')->checkboxList(
Tag::find()->select(['name', 'id'])->indexBy('id')->column()
) ?>
<!-- Single checkbox -->
<?= $form->field($model, 'agree_terms')->checkbox() ?>
<!-- Hidden input -->
<?= $form->field($model, 'user_id')->hiddenInput()->label(false) ?>
<!-- Date picker (HTML5) -->
<?= $form->field($model, 'due_date')->input('date') ?>
<!-- Number input with step -->
<?= $form->field($model, 'price')->input('number', [
'step' => '0.01',
'min' => '0',
]) ?>
<?php ActiveForm::end() ?>Custom Labels, Hints, and Placeholders
<?= $form->field($model, 'email')
->label('Email Address')
->hint('We will never share your email.')
->textInput(['placeholder' => 'you@example.com']) ?>
<!-- Remove label entirely -->
<?= $form->field($model, 'search')
->textInput(['placeholder' => 'Search...'])
->label(false) ?>Custom Error Messages
You can customize error messages at the rule level:
public function rules()
{
return [
[['email'], 'required',
'message' => 'We need your email to send the confirmation.'],
[['password'], 'string', 'min' => 8,
'tooShort' => 'Password must be at least {min} characters.'],
[['username'], 'unique',
'message' => '"{value}" is already taken. Try another.'],
];
}Form Layout Options
// Horizontal form (Bootstrap-style)
<?php $form = ActiveForm::begin([
'id' => 'contact-form',
'fieldConfig' => [
'template' => "{label}\n<div class=\"col-lg-6\">{input}</div>\n"
. "<div class=\"col-lg-6\">{error}\n{hint}</div>",
'labelOptions' => ['class' => 'col-lg-2 control-label'],
],
]) ?>
// Inline form
<?php $form = ActiveForm::begin([
'options' => ['class' => 'form-inline'],
'fieldConfig' => [
'template' => '{input}',
],
]) ?>6. AJAX Validation
Client-side validation catches format errors, but some checks require the server — like checking if an email is unique. AJAX validation sends individual field values to the server as the user types, without submitting the entire form.
Enable AJAX Validation
<?php $form = ActiveForm::begin([
'id' => 'registration-form',
'enableAjaxValidation' => true, // enable for all fields
]) ?>
<!-- Or enable per field -->
<?= $form->field($model, 'email', ['enableAjaxValidation' => true]) ?>
<?= $form->field($model, 'username', ['enableAjaxValidation' => true]) ?>
<?= $form->field($model, 'password') ?> <!-- no AJAX validation for this one -->Handle AJAX Validation in Controller
use yii\web\Response;
use yii\widgets\ActiveForm;
public function actionRegister()
{
$model = new User();
$model->scenario = User::SCENARIO_REGISTER;
// Handle AJAX validation request
if (Yii::$app->request->isAjax && $model->load(Yii::$app->request->post())) {
Yii::$app->response->format = Response::FORMAT_JSON;
return ActiveForm::validate($model);
}
// Handle normal form submission
if ($model->load(Yii::$app->request->post()) && $model->validate()) {
$model->setPassword($model->password);
$model->save(false);
Yii::$app->user->login($model);
return $this->redirect(['site/index']);
}
return $this->render('register', ['model' => $model]);
}How AJAX Validation Works
Validate Only Specific Attributes
To avoid running all validators during AJAX (which could cause side effects):
if (Yii::$app->request->isAjax && $model->load(Yii::$app->request->post())) {
Yii::$app->response->format = Response::FORMAT_JSON;
// Only validate the specific attribute being checked
return ActiveForm::validate($model, ['email', 'username']);
}Validation Timing Options
<?php $form = ActiveForm::begin([
'enableAjaxValidation' => true,
'validationDelay' => 500, // ms before triggering validation
'validateOnChange' => true, // validate when field value changes
'validateOnBlur' => true, // validate when field loses focus
'validateOnType' => false, // validate on each keystroke (expensive)
]) ?>7. File Uploads
File uploads need special handling: the form must use multipart/form-data encoding, the model needs file or image validators, and the controller must process the uploaded file.
Model Setup
class Document extends ActiveRecord
{
/**
* @var UploadedFile
*/
public $uploadedFile;
public function rules()
{
return [
[['title'], 'required'],
[['title'], 'string', 'max' => 255],
// File validation
[['uploadedFile'], 'file',
'extensions' => ['pdf', 'doc', 'docx', 'txt'],
'maxSize' => 5 * 1024 * 1024, // 5 MB
'tooBig' => 'File cannot be larger than 5 MB.',
],
// Required on create, optional on update
[['uploadedFile'], 'required', 'on' => 'create'],
];
}
public static function tableName()
{
return '{{%documents}}';
}
public function attributeLabels()
{
return [
'title' => 'Document Title',
'uploadedFile' => 'Attachment',
];
}
}Image Validation
For images, use the image validator — it extends file with dimension checks:
class UserProfile extends ActiveRecord
{
public $avatarFile;
public function rules()
{
return [
[['avatarFile'], 'image',
'extensions' => ['png', 'jpg', 'jpeg', 'gif', 'webp'],
'maxSize' => 2 * 1024 * 1024, // 2 MB
'minWidth' => 100, 'minHeight' => 100,
'maxWidth' => 2000, 'maxHeight' => 2000,
],
];
}
}Upload Form View
<?php
use yii\helpers\Html;
use yii\widgets\ActiveForm;
$form = ActiveForm::begin([
'options' => ['enctype' => 'multipart/form-data'], // REQUIRED for file upload
]);
?>
<?= $form->field($model, 'title') ?>
<?= $form->field($model, 'uploadedFile')->fileInput([
'accept' => '.pdf,.doc,.docx,.txt',
]) ?>
<?= Html::submitButton('Upload', ['class' => 'btn btn-primary']) ?>
<?php ActiveForm::end() ?>Controller Upload Handling
use yii\web\UploadedFile;
public function actionCreate()
{
$model = new Document();
$model->scenario = 'create';
if ($model->load(Yii::$app->request->post())) {
$model->uploadedFile = UploadedFile::getInstance($model, 'uploadedFile');
if ($model->validate()) {
// Generate unique filename
$filename = Yii::$app->security->generateRandomString(16)
. '.' . $model->uploadedFile->extension;
$uploadPath = Yii::getAlias('@webroot/uploads/' . $filename);
if ($model->uploadedFile->saveAs($uploadPath)) {
$model->file_path = '/uploads/' . $filename;
$model->file_name = $model->uploadedFile->baseName
. '.' . $model->uploadedFile->extension;
$model->file_size = $model->uploadedFile->size;
$model->save(false);
Yii::$app->session->setFlash('success', 'Document uploaded.');
return $this->redirect(['view', 'id' => $model->id]);
}
}
}
return $this->render('create', ['model' => $model]);
}Multiple File Uploads
For uploading multiple files at once:
// Model
class Gallery extends Model
{
/**
* @var UploadedFile[]
*/
public $imageFiles;
public function rules()
{
return [
[['imageFiles'], 'file',
'extensions' => ['png', 'jpg', 'jpeg'],
'maxSize' => 2 * 1024 * 1024,
'maxFiles' => 10,
],
];
}
}// View — note the `[]` in the name attribute and `multiple` option
<?= $form->field($model, 'imageFiles[]')->fileInput([
'multiple' => true,
'accept' => 'image/*',
]) ?>// Controller
$model->imageFiles = UploadedFile::getInstances($model, 'imageFiles');
if ($model->validate()) {
foreach ($model->imageFiles as $file) {
$filename = Yii::$app->security->generateRandomString(16)
. '.' . $file->extension;
$file->saveAs(Yii::getAlias('@webroot/uploads/' . $filename));
}
}Upload Security Checklist
Never trust the client. Always validate on the server.
- Validate file extension AND MIME type — the
filevalidator checks extensions; addcheckExtensionByMimeType => true(default) - Generate random filenames — never use the original filename directly (path traversal risk)
- Store outside webroot if possible — serve files through a controller action with access checks
- Set file size limits — both in PHP (
upload_max_filesize,post_max_size) and in the validator - Limit upload directory permissions — no execute permission
8. Tabular Input
Tabular input lets you edit multiple models in a single form. Common use cases: bulk editing items, adding multiple records at once, or editing a parent record with its children.
The Form — Editing Multiple Tasks
<?php
// views/task/bulk-edit.php
use yii\helpers\Html;
use yii\widgets\ActiveForm;
$form = ActiveForm::begin();
?>
<table class="table">
<thead>
<tr>
<th>Title</th>
<th>Status</th>
<th>Priority</th>
</tr>
</thead>
<tbody>
<?php foreach ($models as $i => $model): ?>
<tr>
<td>
<?= $form->field($model, "[$i]title")
->textInput()
->label(false) ?>
</td>
<td>
<?= $form->field($model, "[$i]status")
->dropDownList([
1 => 'To Do',
2 => 'In Progress',
3 => 'Done',
])
->label(false) ?>
</td>
<td>
<?= $form->field($model, "[$i]priority")
->dropDownList([
1 => 'Low',
2 => 'Medium',
3 => 'High',
])
->label(false) ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?= Html::submitButton('Save All', ['class' => 'btn btn-primary']) ?>
<?php ActiveForm::end() ?>The key is the [$i] prefix in the attribute name. This generates HTML like:
<input name="Task[0][title]" value="First task">
<input name="Task[1][title]" value="Second task">The Controller — Batch Saving
public function actionBulkEdit()
{
$models = Task::find()
->where(['user_id' => Yii::$app->user->id])
->orderBy('id')
->all();
if (Yii::$app->request->isPost) {
$post = Yii::$app->request->post();
$valid = true;
// Load data into each model
foreach ($models as $i => $model) {
if ($model->load($post['Task'][$i] ?? [], '')) {
if (!$model->validate()) {
$valid = false;
}
}
}
if ($valid) {
$transaction = Yii::$app->db->beginTransaction();
try {
foreach ($models as $model) {
$model->save(false);
}
$transaction->commit();
Yii::$app->session->setFlash('success', 'Tasks updated.');
return $this->redirect(['index']);
} catch (\Exception $e) {
$transaction->rollBack();
throw $e;
}
}
}
return $this->render('bulk-edit', ['models' => $models]);
}Dynamic Rows — Adding New Models
For forms where users can add rows dynamically:
// Controller
public function actionBulkCreate()
{
$count = Yii::$app->request->post('count', 3);
$models = [];
if (Yii::$app->request->isPost && isset($_POST['Task'])) {
foreach ($_POST['Task'] as $i => $data) {
$model = new Task();
$model->load($data, '');
$models[] = $model;
}
// Validate all
$valid = true;
foreach ($models as $model) {
if (!$model->validate()) {
$valid = false;
}
}
if ($valid) {
$transaction = Yii::$app->db->beginTransaction();
try {
foreach ($models as $model) {
$model->user_id = Yii::$app->user->id;
$model->save(false);
}
$transaction->commit();
Yii::$app->session->setFlash('success',
count($models) . ' tasks created.');
return $this->redirect(['index']);
} catch (\Exception $e) {
$transaction->rollBack();
throw $e;
}
}
} else {
// Initialize empty models
for ($i = 0; $i < $count; $i++) {
$models[] = new Task();
}
}
return $this->render('bulk-create', ['models' => $models]);
}Add/Remove Rows with JavaScript
// View with dynamic row management
<?php
$this->registerJs(<<<JS
let rowIndex = <?= count($models) ?>;
$('#add-row').on('click', function () {
const template = $('#row-template').html().replace(/__INDEX__/g, rowIndex);
$('tbody').append(template);
rowIndex++;
});
$(document).on('click', '.remove-row', function () {
$(this).closest('tr').remove();
});
JS);
?>
<!-- Hidden template for new rows -->
<script type="text/template" id="row-template">
<tr>
<td><input type="text" name="Task[__INDEX__][title]" class="form-control"></td>
<td>
<select name="Task[__INDEX__][status]" class="form-control">
<option value="1">To Do</option>
<option value="2">In Progress</option>
<option value="3">Done</option>
</select>
</td>
<td><button type="button" class="btn btn-danger remove-row">×</button></td>
</tr>
</script>
<button type="button" id="add-row" class="btn btn-success">+ Add Task</button>9. Putting It All Together — Complete Task Form
Let's build a complete task form that combines everything: ActiveForm, multiple validators, a file attachment, AJAX validation for the title, and proper scenario handling.
The Task Model (Enhanced)
// models/Task.php
namespace app\models;
use yii\db\ActiveRecord;
use yii\web\UploadedFile;
class Task extends ActiveRecord
{
const STATUS_TODO = 1;
const STATUS_IN_PROGRESS = 2;
const STATUS_DONE = 3;
const SCENARIO_CREATE = 'create';
const SCENARIO_UPDATE = 'update';
/**
* @var UploadedFile
*/
public $attachmentFile;
public static function tableName()
{
return '{{%tasks}}';
}
public function rules()
{
return [
// Filters (run first)
[['title', 'description'], 'trim'],
[['status'], 'default', 'value' => self::STATUS_TODO],
[['priority'], 'default', 'value' => 2],
// Required fields
[['title'], 'required'],
[['title'], 'string', 'min' => 3, 'max' => 255],
[['title'], 'unique',
'targetAttribute' => ['title', 'user_id'],
'message' => 'You already have a task with this title.'],
// Optional fields
[['description'], 'string', 'max' => 5000],
[['status'], 'in', 'range' => [
self::STATUS_TODO,
self::STATUS_IN_PROGRESS,
self::STATUS_DONE,
]],
[['priority'], 'integer', 'min' => 1, 'max' => 3],
// Date validation
[['due_date'], 'date', 'format' => 'php:Y-m-d'],
[['due_date'], 'validateFutureDate', 'on' => self::SCENARIO_CREATE],
// File attachment
[['attachmentFile'], 'file',
'extensions' => ['pdf', 'doc', 'docx', 'png', 'jpg'],
'maxSize' => 5 * 1024 * 1024,
],
// Foreign key
[['user_id'], 'exist',
'targetClass' => User::class,
'targetAttribute' => 'id'],
];
}
public function validateFutureDate($attribute)
{
if ($this->$attribute && strtotime($this->$attribute) < strtotime('today')) {
$this->addError($attribute, 'Due date must be today or later.');
}
}
public function attributeLabels()
{
return [
'title' => 'Task Title',
'description' => 'Description',
'status' => 'Status',
'priority' => 'Priority',
'due_date' => 'Due Date',
'attachmentFile' => 'Attachment',
];
}
public function getStatusLabel()
{
$labels = [
self::STATUS_TODO => 'To Do',
self::STATUS_IN_PROGRESS => 'In Progress',
self::STATUS_DONE => 'Done',
];
return $labels[$this->status] ?? 'Unknown';
}
public function getUser()
{
return $this->hasOne(User::class, ['id' => 'user_id']);
}
}The View — Full Featured Form
<?php
// views/task/_form.php
use yii\helpers\Html;
use yii\widgets\ActiveForm;
$form = ActiveForm::begin([
'id' => 'task-form',
'enableAjaxValidation' => true,
'options' => ['enctype' => 'multipart/form-data'],
'fieldConfig' => [
'options' => ['class' => 'form-group'],
],
]);
?>
<?= $form->field($model, 'title', ['enableAjaxValidation' => true])
->textInput(['maxlength' => true, 'autofocus' => true])
->hint('Must be unique among your tasks.') ?>
<?= $form->field($model, 'description')
->textarea(['rows' => 5, 'placeholder' => 'Describe the task...']) ?>
<div class="row">
<div class="col-md-4">
<?= $form->field($model, 'status')->dropDownList([
1 => 'To Do',
2 => 'In Progress',
3 => 'Done',
]) ?>
</div>
<div class="col-md-4">
<?= $form->field($model, 'priority')->radioList([
1 => 'Low',
2 => 'Medium',
3 => 'High',
]) ?>
</div>
<div class="col-md-4">
<?= $form->field($model, 'due_date')->input('date') ?>
</div>
</div>
<?= $form->field($model, 'attachmentFile')->fileInput([
'accept' => '.pdf,.doc,.docx,.png,.jpg',
]) ?>
<?php if ($model->attachment_path): ?>
<p class="text-muted">
Current file: <?= Html::encode($model->attachment_name) ?>
(<?= Yii::$app->formatter->asShortSize($model->attachment_size) ?>)
</p>
<?php endif; ?>
<div class="form-group">
<?= Html::submitButton(
$model->isNewRecord ? 'Create Task' : 'Update Task',
['class' => $model->isNewRecord ? 'btn btn-success' : 'btn btn-primary']
) ?>
<?= Html::a('Cancel', ['index'], ['class' => 'btn btn-default']) ?>
</div>
<?php ActiveForm::end() ?>The Controller — Create and Update
// controllers/TaskController.php
use yii\web\Response;
use yii\web\UploadedFile;
use yii\widgets\ActiveForm;
public function actionCreate()
{
$model = new Task();
$model->scenario = Task::SCENARIO_CREATE;
$model->user_id = Yii::$app->user->id;
// AJAX validation
if (Yii::$app->request->isAjax && $model->load(Yii::$app->request->post())) {
Yii::$app->response->format = Response::FORMAT_JSON;
return ActiveForm::validate($model);
}
if ($model->load(Yii::$app->request->post())) {
$model->attachmentFile = UploadedFile::getInstance($model, 'attachmentFile');
if ($model->validate()) {
// Handle file upload
if ($model->attachmentFile) {
$this->saveAttachment($model);
}
$model->save(false);
Yii::$app->session->setFlash('success', 'Task created.');
return $this->redirect(['view', 'id' => $model->id]);
}
}
return $this->render('create', ['model' => $model]);
}
public function actionUpdate($id)
{
$model = $this->findTask($id);
$model->scenario = Task::SCENARIO_UPDATE;
// AJAX validation
if (Yii::$app->request->isAjax && $model->load(Yii::$app->request->post())) {
Yii::$app->response->format = Response::FORMAT_JSON;
return ActiveForm::validate($model);
}
if ($model->load(Yii::$app->request->post())) {
$model->attachmentFile = UploadedFile::getInstance($model, 'attachmentFile');
if ($model->validate()) {
if ($model->attachmentFile) {
// Delete old file if replacing
if ($model->attachment_path) {
$oldPath = Yii::getAlias('@webroot' . $model->attachment_path);
if (file_exists($oldPath)) {
unlink($oldPath);
}
}
$this->saveAttachment($model);
}
$model->save(false);
Yii::$app->session->setFlash('success', 'Task updated.');
return $this->redirect(['view', 'id' => $model->id]);
}
}
return $this->render('update', ['model' => $model]);
}
private function saveAttachment(Task $model)
{
$filename = Yii::$app->security->generateRandomString(16)
. '.' . $model->attachmentFile->extension;
$path = '/uploads/tasks/' . $filename;
$model->attachmentFile->saveAs(Yii::getAlias('@webroot' . $path));
$model->attachment_path = $path;
$model->attachment_name = $model->attachmentFile->baseName
. '.' . $model->attachmentFile->extension;
$model->attachment_size = $model->attachmentFile->size;
}
private function findTask($id)
{
$model = Task::findOne([
'id' => $id,
'user_id' => Yii::$app->user->id,
]);
if ($model === null) {
throw new \yii\web\NotFoundHttpException('Task not found.');
}
return $model;
}Request Flow
10. Common Patterns & Tips
Pattern 1: Form Model for Complex Forms
When a form doesn't map directly to a single database table, use a form model:
// models/ContactForm.php
namespace app\models;
use yii\base\Model;
class ContactForm extends Model
{
public $name;
public $email;
public $subject;
public $body;
public function rules()
{
return [
[['name', 'email', 'subject', 'body'], 'required'],
[['email'], 'email'],
[['subject'], 'string', 'max' => 128],
[['body'], 'string', 'max' => 5000],
];
}
public function sendEmail()
{
if (!$this->validate()) {
return false;
}
return Yii::$app->mailer->compose()
->setTo(Yii::$app->params['adminEmail'])
->setFrom([Yii::$app->params['senderEmail'] => $this->name])
->setReplyTo([$this->email => $this->name])
->setSubject($this->subject)
->setTextBody($this->body)
->send();
}
}Pattern 2: Dependent Dropdowns
When one dropdown depends on another (e.g., country → city):
// View
<?= $form->field($model, 'country_id')->dropDownList(
Country::find()->select(['name', 'id'])->indexBy('id')->column(),
[
'prompt' => '-- Select Country --',
'id' => 'country-id',
]
) ?>
<?= $form->field($model, 'city_id')->dropDownList([], [
'prompt' => '-- Select City --',
'id' => 'city-id',
]) ?>
<?php
$this->registerJs(<<<JS
$('#country-id').on('change', function () {
var countryId = $(this).val();
if (!countryId) {
$('#city-id').html('<option value="">-- Select City --</option>');
return;
}
$.get('/api/cities', { country_id: countryId }, function (data) {
var options = '<option value="">-- Select City --</option>';
$.each(data, function (id, name) {
options += '<option value="' + id + '">' + name + '</option>';
});
$('#city-id').html(options);
});
});
JS);
?>// Controller action for the API
public function actionCities($country_id)
{
Yii::$app->response->format = Response::FORMAT_JSON;
return City::find()
->select(['name', 'id'])
->where(['country_id' => $country_id])
->indexBy('id')
->column();
}Pattern 3: Confirmation Before Destructive Actions
<?= Html::a('Delete', ['delete', 'id' => $model->id], [
'class' => 'btn btn-danger',
'data' => [
'confirm' => 'Are you sure you want to delete this task?',
'method' => 'post',
],
]) ?>Yii2's JavaScript automatically converts this into a confirmation dialog + POST request.
Pattern 4: Cross-Attribute Validation
When validation depends on multiple fields:
public function rules()
{
return [
[['discount_percent'], 'number', 'min' => 0, 'max' => 100],
[['discount_code'], 'required',
'when' => function ($model) {
return $model->discount_percent > 0;
},
'whenClient' => "function (attribute, value) {
return parseFloat($('#order-discount_percent').val()) > 0;
}",
'message' => 'Discount code is required when offering a discount.',
],
];
}Debugging Validation
When validation fails and you're not sure why:
$model->load(Yii::$app->request->post());
if (!$model->validate()) {
// See all validation errors
var_dump($model->errors);
// Output: ['title' => ['Title cannot be blank.'], 'email' => [...]]
// Check which attributes were loaded (safe attributes)
var_dump($model->safeAttributes());
// Check the current scenario
var_dump($model->scenario);
// Check which validators are active
var_dump($model->getActiveValidators());
}Performance Tip: Skip Validation When Safe
When you've already validated and just need to save:
// save(false) = skip validation
$model->save(false);
// updateAll doesn't trigger validation or events
Task::updateAll(['status' => Task::STATUS_DONE], ['due_date' => '<' . date('Y-m-d')]);Use save(false) only when you've explicitly called validate() earlier in the same request. Never use it on unvalidated user input.
What's Next
Your task manager now has a complete form system:
- ActiveForm generates fields with client-side validation from
rules() - Built-in validators handle strings, emails, dates, unique checks, and more
- Custom validators cover domain-specific logic
- Scenarios let the same model enforce different rules per context
- File uploads are validated and stored securely
- Tabular input handles bulk operations
- AJAX validation gives real-time feedback without page reload
Deep Dive → RESTful API Development with Yii2 — build a complete REST API with ActiveController, token authentication, rate limiting, serialization, and CORS.
📬 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.