Back to blog

Yii2 Forms, Validation & Data Input

yii2phpformsvalidationbackend
Yii2 Forms, Validation & Data Input

Phase 4 đã khóa chặt task manager với authentication và RBAC. Nhưng mọi tương tác với user đều bắt đầu từ form — registration, login, tạo task, cập nhật profile. Form làm sai thì bạn nhận được dữ liệu rác, lỗ hổng security, và user bực bội.

Hệ thống form của Yii2 là một trong những feature mạnh nhất. ActiveForm generate HTML, kết nối client-side validation, xử lý AJAX validation, hiển thị error — tất cả từ cùng rules() mà bạn đã viết trên model. Một nguồn duy nhất cho validation logic, không duplicate gì giữa server và client.

Kết thúc bài này, bạn sẽ build được form validate trên cả client lẫn server, xử lý file uploads, lưu nhiều model cùng lúc với tabular input, và điều chỉnh validation rules theo context bằng scenarios.

Bạn Sẽ Build Gì

✅ ActiveForm widget với automatic client-side validation
✅ Tất cả built-in validators chính (required, string, email, unique, compare, v.v.)
✅ Custom inline validators và standalone Validator classes
✅ Model scenarios cho context-dependent validation
✅ File và image upload với validation
✅ Tabular input — lưu nhiều models trong một form
✅ AJAX validation cho real-time server-side checks


1. ActiveForm Cơ Bản

ActiveForm Hoạt Động Như Thế Nào

ActiveForm là widget generate <form> tags, input fields, labels, error messages, và client-side validation — tất cả driven bởi rules() của model. Khi bạn gọi $form->field($model, 'attribute'), Yii2:

  1. Đọc rules() của model để xác định validators nào áp dụng
  2. Generate HTML input với đúng name, id, và value
  3. Thêm aria attributes cho accessibility
  4. Gắn JavaScript validation handlers
  5. Hiển thị label của attribute từ attributeLabels()
  6. Dành chỗ cho error messages

Cấu Trúc Form Cơ Bản

<?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() ?>

HTML Được Generate Ra

Code đơn giản đó generate ra:

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

Những điểm cần chú ý:

  • CSRF token tự động được thêm vào
  • name="Task[title]" — tên model class trở thành array key (đây là lý do $model->load(Yii::$app->request->post()) hoạt động)
  • required CSS class tự động thêm từ rules()
  • aria-required cho accessibility
  • help-block div dành cho error messages

Phía Controller

// 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]);
}

Pattern luôn giống nhau:

  1. Tạo model
  2. Thử load() POST data và save() (tự gọi validate() bên trong)
  3. Nếu validation fail, re-render form — errors đã gắn sẵn trên model

2. Built-in Validators

Yii2 đi kèm ~25 built-in validators. Đây là những cái bạn dùng nhiều nhất, tổ chức theo category.

Required & Type Validators

public function rules()
{
    return [
        // Required — field không được trống
        [['title', 'email'], 'required'],
        [['title'], 'required', 'message' => 'Please enter a title.'],
 
        // String — ràng buộc độ dài
        [['title'], 'string', 'max' => 255],
        [['description'], 'string', 'max' => 5000],
        [['username'], 'string', 'min' => 3, 'max' => 32],
        [['code'], 'string', 'length' => 6], // độ dài chính xác
 
        // 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 có 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 [
        // So sánh hai attributes
        [['password_repeat'], 'compare', 'compareAttribute' => 'password'],
        [['end_date'], 'compare', 'compareAttribute' => 'start_date',
            'operator' => '>=', 'type' => 'datetime'],
 
        // Unique trong database
        [['email'], 'unique'],
        [['email'], 'unique', 'targetClass' => User::class,
            'message' => 'This email is already taken.'],
 
        // Composite unique (tổ hợp phải unique)
        [['user_id'], 'unique',
            'targetAttribute' => ['user_id', 'project_id'],
            'message' => 'User already assigned to this project.'],
 
        // Exist — foreign key phải tồn tại trong bảng khác
        [['category_id'], 'exist',
            'targetClass' => Category::class,
            'targetAttribute' => 'id'],
    ];
}

Filter Validators

Những cái này thay đổi giá trị thay vì reject:

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));
        }],
    ];
}

Validator safe

safe không validate — nó đánh dấu attributes là safe cho mass assignment:

public function rules()
{
    return [
        // Những attributes này có thể set qua load()
        [['notes', 'metadata'], 'safe'],
    ];
}

Warning: Chỉ dùng safe cho attributes thật sự không cần validation. Nếu attribute có bất kỳ constraint nào, dùng validator thật thay thế.

Bảng Tham Khảo Nhanh Validators

ValidatorMục đíchKey Options
requiredKhông được trốngwhen, message
stringRàng buộc độ dàimin, max, length
integerPhải là integermin, max
numberPhải là sốmin, max
booleanPhải là 0/1trueValue, falseValue
emailFormat email hợp lệcheckDNS
urlURL hợp lệdefaultScheme
matchRegex patternpattern, not
compareSo sánh với field kháccompareAttribute, operator
uniqueUnique trong DB tabletargetClass, targetAttribute
existPhải tồn tại trong DBtargetClass, targetAttribute
dateFormat date hợp lệformat, min, max
fileValidation file uploadextensions, maxSize
imageValidation image fileminWidth, maxWidth
inGiá trị trong danh sách cho phéprange, strict
trimBỏ whitespace—
defaultSet giá trị mặc địnhvalue
filterTransform giá trịfilter
safeCho phép mass assignment—

3. Custom Validators

Khi built-in validators không đủ, Yii2 cho bạn hai lựa chọn: inline validators cho rules dùng một lần và standalone validator classes cho logic tái sử dụng.

Inline Validators

Inline validator là method trên model của bạn:

class Task extends ActiveRecord
{
    public function rules()
    {
        return [
            [['due_date'], 'validateFutureDate'],
            [['title'], 'validateNoProfanity'],
        ];
    }
 
    /**
     * Due date phải ở tương lai (chỉ cho task mới).
     */
    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.');
            }
        }
    }
 
    /**
     * Chặn từ ngữ không phù hợp trong 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;
            }
        }
    }
}

Những điểm chính:

  • Method signature: function validateXxx($attribute, $params)
  • Dùng $this->$attribute để truy cập giá trị
  • Gọi $this->addError($attribute, $message) để đánh dấu invalid
  • Check $this->hasErrors() trước để skip nếu validators khác đã fail

Inline Validator với when (Conditional Validation)

public function rules()
{
    return [
        // Chỉ require reason khi status là 'rejected'
        [['rejection_reason'], 'required', 'when' => function ($model) {
            return $model->status === self::STATUS_REJECTED;
        }, 'whenClient' => "function (attribute, value) {
            return $('#task-status').val() == '3';
        }"],
    ];
}

Callback when chạy trên server. whenClient là JavaScript function cho client-side validation — phải là string JS code.

Standalone Validator Class

Cho validation logic tái sử dụng, tạo 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; // hợp lệ
    }
}

Sử dụng trong model:

use app\validators\PhoneNumberValidator;
 
public function rules()
{
    return [
        [['phone'], PhoneNumberValidator::class, 'country' => 'VN'],
    ];
}

Standalone Validator với validateAttribute

Khi bạn cần truy cập model (không chỉ giá trị), 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.");
        }
    }
}
// Trong model:
public function rules()
{
    return [
        [['end_date'], DateRangeValidator::class,
            'startAttribute' => 'start_date',
            'maxDays' => 90],
    ];
}

4. Model Scenarios

Scenario cho phép bạn áp dụng validation rules khác nhau cho cùng model tùy theo context. Model User cần rules khác nhau cho registration vs profile update vs admin edit.

Định Nghĩa 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 [
            // Áp dụng trong TẤT CẢ scenarios (không có 'on')
            [['username', 'email'], 'required'],
            [['username'], 'string', 'min' => 3, 'max' => 32],
            [['email'], 'email'],
            [['email'], 'unique'],
 
            // Chỉ khi 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],
 
            // Khi update profile — password là optional
            [['password'], 'string', 'min' => 8,
                'on' => self::SCENARIO_UPDATE_PROFILE],
 
            // Admin có thể set role và status
            [['role', 'status'], 'required',
                'on' => self::SCENARIO_ADMIN_EDIT],
            [['role'], 'in', 'range' => ['user', 'editor', 'admin'],
                'on' => self::SCENARIO_ADMIN_EDIT],
        ];
    }
}

Sử Dụng Scenarios Trong 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 (đã validate rồi)
        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 chỉnh sử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 và Safe Attributes

Một chi tiết quan trọng: chỉ attributes xuất hiện trong active rules cho scenario hiện tại mới safe cho mass assignment. Nghĩa là load() sẽ âm thầm bỏ qua attributes không có rules trong scenario hiện tại.

// Trong SCENARIO_REGISTER, những attributes này safe:
// username, email, password, password_repeat
 
// Trong SCENARIO_UPDATE_PROFILE, những attributes này safe:
// username, email, password
 
// Trong SCENARIO_ADMIN_EDIT, những attributes này safe:
// username, email, role, status

Đây là security feature — users không thể inject giá trị cho fields mà họ không được phép thay đổi.

Override scenarios() Để Kiểm Soát Chi Tiết

Nếu bạn cần kiểm soát rõ ràng attributes nào safe cho mỗi 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 với ! = không safe (không thể mass-assign)
    ];
    return $scenarios;
}

5. ActiveForm Features Nâng Cao

Các Loại Field

ActiveForm::field() trả về object ActiveField với fluent methods để customize rendering:

<?php $form = ActiveForm::begin() ?>
 
<!-- Text input (mặc định) -->
<?= $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 (cho 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 với step -->
<?= $form->field($model, 'price')->input('number', [
    'step' => '0.01',
    'min' => '0',
]) ?>
 
<?php ActiveForm::end() ?>

Custom Labels, Hints, và Placeholders

<?= $form->field($model, 'email')
    ->label('Email Address')
    ->hint('We will never share your email.')
    ->textInput(['placeholder' => '[email protected]']) ?>
 
<!-- Bỏ label hoàn toàn -->
<?= $form->field($model, 'search')
    ->textInput(['placeholder' => 'Search...'])
    ->label(false) ?>

Custom Error Messages

Bạn có thể customize error messages ở 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 bắt lỗi format, nhưng một số checks cần server — như kiểm tra email có unique không. AJAX validation gửi giá trị field riêng lẻ đến server khi user gõ, mà không submit toàn bộ form.

Bật AJAX Validation

<?php $form = ActiveForm::begin([
    'id' => 'registration-form',
    'enableAjaxValidation' => true,  // bật cho tất cả fields
]) ?>
 
<!-- Hoặc bật cho từng field -->
<?= $form->field($model, 'email', ['enableAjaxValidation' => true]) ?>
<?= $form->field($model, 'username', ['enableAjaxValidation' => true]) ?>
<?= $form->field($model, 'password') ?> <!-- không AJAX validation cho field này -->

Xử Lý AJAX Validation Trong Controller

use yii\web\Response;
use yii\widgets\ActiveForm;
 
public function actionRegister()
{
    $model = new User();
    $model->scenario = User::SCENARIO_REGISTER;
 
    // Xử lý 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);
    }
 
    // Xử lý form submission bình thường
    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]);
}

AJAX Validation Hoạt Động Thế Nào

Validate Chỉ Specific Attributes

Để tránh chạy tất cả validators khi AJAX (có thể gây side effects):

if (Yii::$app->request->isAjax && $model->load(Yii::$app->request->post())) {
    Yii::$app->response->format = Response::FORMAT_JSON;
    // Chỉ validate attribute cụ thể đang được check
    return ActiveForm::validate($model, ['email', 'username']);
}

Validation Timing Options

<?php $form = ActiveForm::begin([
    'enableAjaxValidation' => true,
    'validationDelay' => 500,        // ms trước khi trigger validation
    'validateOnChange' => true,       // validate khi giá trị field thay đổi
    'validateOnBlur' => true,         // validate khi field mất focus
    'validateOnType' => false,        // validate mỗi keystroke (tốn kém)
]) ?>

7. File Uploads

File uploads cần xử lý đặc biệt: form phải dùng multipart/form-data encoding, model cần file hoặc image validators, và controller phải xử lý uploaded file.

Thiết Lập Model

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 khi create, optional khi update
            [['uploadedFile'], 'required', 'on' => 'create'],
        ];
    }
 
    public static function tableName()
    {
        return '{{%documents}}';
    }
 
    public function attributeLabels()
    {
        return [
            'title' => 'Document Title',
            'uploadedFile' => 'Attachment',
        ];
    }
}

Image Validation

Cho images, dùng image validator — nó mở rộng file với 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'],  // BẮT BUỘC cho 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() ?>

Xử Lý Upload Trong Controller

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 tên file unique
            $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

Để upload nhiều files cùng lúc:

// 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 — chú ý `[]` trong name attribute và option `multiple`
<?= $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));
    }
}

Checklist Security Upload

Đừng bao giờ tin client. Luôn validate trên server.

  1. Validate cả file extension VÀ MIME type — file validator check extensions; thêm checkExtensionByMimeType => true (mặc định)
  2. Generate random filenames — đừng bao giờ dùng tên file gốc trực tiếp (rủi ro path traversal)
  3. Lưu ngoài webroot nếu có thể — serve files qua controller action với access checks
  4. Set giới hạn file size — cả trong PHP (upload_max_filesize, post_max_size) và trong validator
  5. Giới hạn permissions thư mục upload — không execute permission

8. Tabular Input

Tabular input cho phép bạn chỉnh sửa nhiều models trong một form. Các use cases phổ biến: bulk editing items, thêm nhiều records cùng lúc, hoặc chỉnh sửa parent record cùng children.

Form — Chỉnh Sửa Nhiều 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() ?>

Chìa khóa là prefix [$i] trong attribute name. Nó generate HTML kiểu:

<input name="Task[0][title]" value="First task">
<input name="Task[1][title]" value="Second task">

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 vào mỗi 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 — Thêm Models Mới

Cho forms mà users có thể thêm rows động:

// 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 tất cả
        $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 {
        // Khởi tạo empty models
        for ($i = 0; $i < $count; $i++) {
            $models[] = new Task();
        }
    }
 
    return $this->render('bulk-create', ['models' => $models]);
}

Thêm/Xóa Rows Bằng JavaScript

// View với 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 cho rows mới -->
<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. Ghép Tất Cả Lại — Complete Task Form

Hãy build một complete task form kết hợp mọi thứ: ActiveForm, nhiều validators, file attachment, AJAX validation cho title, và xử lý scenario đúng cách.

Task Model (Nâng Cao)

// 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 (chạy trước)
            [['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']);
    }
}

View — Form Đầy Đủ Feature

<?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">
        File hiện tại: <?= 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() ?>

Controller — Create và 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()) {
            // Xử lý 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) {
                // Xóa file cũ nếu thay thế
                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 Cho Complex Forms

Khi form không map trực tiếp tới một database table, dùng 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

Khi dropdown phụ thuộc vào dropdown khác (ví dụ: 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 cho 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: Xác Nhận Trước 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',
    ],
]) ?>

JavaScript của Yii2 tự động chuyển thành confirmation dialog + POST request.

Pattern 4: Cross-Attribute Validation

Khi validation phụ thuộc vào nhiều 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.',
        ],
    ];
}

Debug Validation

Khi validation fail mà bạn không chắc tại sao:

$model->load(Yii::$app->request->post());
 
if (!$model->validate()) {
    // Xem tất cả validation errors
    var_dump($model->errors);
    // Output: ['title' => ['Title cannot be blank.'], 'email' => [...]]
 
    // Check attributes nào đã được load (safe attributes)
    var_dump($model->safeAttributes());
 
    // Check scenario hiện tại
    var_dump($model->scenario);
 
    // Check validators nào đang active
    var_dump($model->getActiveValidators());
}

Performance Tip: Skip Validation Khi Đã An Toàn

Khi bạn đã validate và chỉ cần save:

// save(false) = skip validation
$model->save(false);
 
// updateAll không trigger validation hay events
Task::updateAll(['status' => Task::STATUS_DONE], ['due_date' => '<' . date('Y-m-d')]);

Chỉ dùng save(false) khi bạn đã gọi validate() rõ ràng trước đó trong cùng request. Đừng bao giờ dùng trên user input chưa validate.


Tiếp Theo

Task manager của bạn giờ đã có complete form system:

  • ActiveForm generate fields với client-side validation từ rules()
  • Built-in validators xử lý strings, emails, dates, unique checks, và nhiều hơn
  • Custom validators cover domain-specific logic
  • Scenarios cho phép cùng model enforce rules khác nhau theo context
  • File uploads được validate và lưu trữ an toàn
  • Tabular input xử lý bulk operations
  • AJAX validation cho real-time feedback không cần reload trang

Deep Dive → RESTful API Development với Yii2 — build complete REST API với ActiveController, token authentication, rate limiting, serialization, và CORS.

← Authentication & Authorization: RBAC | → Tổng Quan Series

📬 Đăng ký nhận bản tin

Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.

Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.

💬 Comments

Đăng nhập để bình luận

Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.