Back to blog

Yii2 Forms, Validation & Data Input

yii2phpformsvalidationbackend
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:

  1. Reads the model's rules() to determine which validators apply
  2. Generates HTML input with proper name, id, and value
  3. Adds aria attributes for accessibility
  4. Attaches JavaScript validation handlers
  5. Displays the attribute's label from attributeLabels()
  6. 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)
  • required CSS class added automatically from rules()
  • aria-required for accessibility
  • help-block div 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:

  1. Create model
  2. Try to load() POST data and save() (which calls validate() internally)
  3. 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 safe for attributes that genuinely don't need validation. If an attribute has any constraint, use a real validator instead.

Validator Quick Reference

ValidatorPurposeKey Options
requiredMust not be emptywhen, message
stringLength constraintsmin, max, length
integerMust be integermin, max
numberMust be numericmin, max
booleanMust be 0/1trueValue, falseValue
emailValid email formatcheckDNS
urlValid URLdefaultScheme
matchRegex patternpattern, not
compareCompare to another fieldcompareAttribute, operator
uniqueUnique in DB tabletargetClass, targetAttribute
existMust exist in DBtargetClass, targetAttribute
dateValid date formatformat, min, max
fileFile upload validationextensions, maxSize
imageImage file validationminWidth, maxWidth
inValue in allowed listrange, strict
trimStrip whitespace
defaultSet default valuevalue
filterTransform valuefilter
safeAllow 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->$attribute to 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, status

This 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.

  1. Validate file extension AND MIME type — the file validator checks extensions; add checkExtensionByMimeType => true (default)
  2. Generate random filenames — never use the original filename directly (path traversal risk)
  3. Store outside webroot if possible — serve files through a controller action with access checks
  4. Set file size limits — both in PHP (upload_max_filesize, post_max_size) and in the validator
  5. 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']);
    }
}
<?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.

← Authentication & Authorization: RBAC | → Series Overview

📬 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.