Advanced Multi-Model Forms in Yii2
Manage multiple models in a form, supporting validation for each model. For a multi child model see Advanced Multi-Model Forms with HASMANY Relations.
Consider a Product
model that has a single Parcel
model related, which represents a
parcel that belongs to the product. In the form you want to enter information for the
product and the parcel at the same time, and each one should validate according to the model rules.
Table Models
Start with the following tables:
CREATE TABLE `product` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL,
PRIMARY KEY (`id`)
);
CREATE TABLE `parcel` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`product_id` int(11) NOT NULL,
`code` varchar(255) NOT NULL,
`height` int(11) NOT NULL,
`width` int(11) NOT NULL,
`depth` int(11) NOT NULL,
PRIMARY KEY (`id`),
KEY `product_id` (`product_id`),
CONSTRAINT `parcel_product_id` FOREIGN KEY (`product_id`) REFERENCES `product` (`id`)
);
And the models that represent the tables are:
models/Product.php
<?php
namespace app\models;
use \yii\db\ActiveRecord;
class Product extends ActiveRecord
{
public static function tableName()
{
return 'product';
}
public function rules()
{
return [
[['name'], 'required'],
[['name'], 'string', 'max' => 255]
];
}
public function getParcel()
{
return $this->hasOne(Parcel::className(), ['product_id' => 'id']);
}
}
models/Parcel.php
<?php
namespace app\models;
use \yii\db\ActiveRecord;
class Parcel extends ActiveRecord
{
public static function tableName()
{
return 'parcel';
}
public function rules()
{
return [
[['code', 'height', 'width', 'depth'], 'required'],
[['product_id', 'height', 'width', 'depth'], 'integer'],
[['code'], 'string', 'max' => 255],
[['product_id'], 'exist',
'skipOnError' => true,
'targetClass' => Product::className(),
'targetAttribute' => ['product_id' => 'id']]
];
}
public function getProduct()
{
return $this->hasOne(Product::className(), ['id' => 'product_id']);
}
}
The Form Model
Now we get to the advanced form, but don’t worry, it’s all quite straight forward. We need a model to handle loading, validating and saving the data.
The purpose of ProductForm
form is to:
- handle loading
Product
andParcel
models - assigning the submitted data to model attributes
- saving data after validation
- displaying error summaries on the form
models/form/ProductForm
<?php
namespace app\models\form;
use app\models\Product;
use app\models\Parcel;
use Yii;
use yii\base\Model;
use yii\widgets\ActiveForm;
class ProductForm extends Model
{
private $_product;
private $_parcel;
public function rules()
{
return [
[['Product'], 'required'],
[['Parcel'], 'safe'],
];
}
public function afterValidate()
{
$error = false;
if (!$this->product->validate()) {
$error = true;
}
if (!$this->parcel->validate()) {
$error = true;
}
if ($error) {
$this->addError(null); // add an empty error to prevent saving
}
parent::afterValidate();
}
public function save()
{
if (!$this->validate()) {
return false;
}
$transaction = Yii::$app->db->beginTransaction();
if (!$this->product->save()) {
$transaction->rollBack();
return false;
}
$this->parcel->product_id = $this->product->id;
if (!$this->parcel->save(false)) {
$transaction->rollBack();
return false;
}
$transaction->commit();
return true;
}
public function getProduct()
{
return $this->_product;
}
public function setProduct($product)
{
if ($product instanceof Product) {
$this->_product = $product;
} else if (is_array($product)) {
$this->_product->setAttributes($product);
}
}
public function getParcel()
{
if ($this->_parcel === null) {
if ($this->product->isNewRecord) {
$this->_parcel = new Parcel();
$this->_parcel->loadDefaultValues();
} else {
$this->_parcel = $this->product->parcel;
}
}
return $this->_parcel;
}
public function setParcel($parcel)
{
if (is_array($parcel)) {
$this->parcel->setAttributes($parcel);
} elseif ($parcel instanceof Parcel) {
$this->_parcel = $parcel;
}
}
public function errorSummary($form)
{
$errorLists = [];
foreach ($this->getAllModels() as $id => $model) {
$errorList = $form->errorSummary($model, [
'header' => '<p>Please fix the following errors for <b>' . $id . '</b></p>',
]);
$errorList = str_replace('<li></li>', '', $errorList); // remove the empty error
$errorLists[] = $errorList;
}
return implode('', $errorLists);
}
private function getAllModels()
{
return [
'Product' => $this->product,
'Parcel' => $this->parcel,
];
}
}
Controller Actions
The ProductForm
class is used in actionUpdate()
and actionCreate()
methods in ProductController
.
controllers/ProductController.php
<?php
namespace app\controllers;
use app\models\form\ProductForm;
use app\models\Product;
use Yii;
use yii\web\Controller;
class ProductController extends Controller
{
public function actionCreate()
{
$productForm = new ProductForm();
$productForm->product = new Product;
$productForm->setAttributes(Yii::$app->request->post());
if (Yii::$app->request->post() && $productForm->save()) {
Yii::$app->getSession()->setFlash('success', 'Product has been created.');
return $this->redirect(['update', 'id' => $productForm->product->id]);
} elseif (!Yii::$app->request->isPost) {
$productForm->load(Yii::$app->request->get());
}
return $this->render('create', ['productForm' => $productForm]);
}
public function actionUpdate($id)
{
$productForm = new ProductForm();
$productForm->product = $this->findModel($id);
$productForm->setAttributes(Yii::$app->request->post());
if (Yii::$app->request->post() && $productForm->save()) {
Yii::$app->getSession()->setFlash('success', 'Product has been updated.');
return $this->redirect(['update', 'id' => $productForm->product->id]);
} elseif (!Yii::$app->request->isPost) {
$productForm->load(Yii::$app->request->get());
}
return $this->render('update', ['productForm' => $productForm]);
}
protected function findModel($id)
{
if (($model = Product::findOne($id)) !== null) {
return $model;
}
throw new HttpException(404, 'The requested page does not exist.');
}
}
Form Views
The views views/product/create.php
and views/product/update.php
will both render a form.
<?= $this->render('_form', ['productForm' => $productForm]); ?>
The form will have a section for the Product, and another section for the Parcels.
After saving, each Product and Parcel field will be validated individually, if any fail the error will be displayed at the top as well as on the errored field.
views/product/_form.php
<?php
use yii\helpers\Html;
use yii\widgets\ActiveForm;
?>
<div class="product-form">
<?php $form = ActiveForm::begin(); ?>
<?= $productForm->errorSummary($form); ?>
<fieldset>
<legend>Product</legend>
<?= $form->field($productForm->product, 'name')->textInput() ?>
</fieldset>
<fieldset>
<legend>Parcel</legend>
<?= $form->field($productForm->parcel, 'code')->textInput() ?>
<?= $form->field($productForm->parcel, 'width')->textInput() ?>
<?= $form->field($productForm->parcel, 'height')->textInput() ?>
<?= $form->field($productForm->parcel, 'depth')->textInput() ?>
</fieldset>
<?= Html::submitButton('Save'); ?>
<?php ActiveForm::end(); ?>
</div>
Additional Information
The $_POST
data will look something like the following:
$_POST = [
'Product' => [
'name' => 'Keyboard and Mouse',
],
'Parcel' => [
'code' => 'keyboard',
'width' => '50',
'height' => '5',
'depth' => '20',
],
];
Tagged with : Yii