データベース

itemsというテーブルを作ります。
listカラムにjsonで保存するようにします。

config/Migrations/xxxxxx_CreateItems.php

<?php
declare(strict_types=1);

use Migrations\AbstractMigration;

class CreateItems extends AbstractMigration
{
	public function change()
	{
		$table = $this->table('items');
		$table
			->addColumn('title', 'string')
			->addColumn('list', 'text')
			->addColumn('created', 'datetime')
			->addColumn('modified', 'datetime')
			->create();
	}
}

モデル

モデルはbakeしたものをそのまま使用します。
バリデーションだけ複数入力用にカスタマイズしましょう。

src/Model/Table/ItemsTable.php

class ItemsTable extends Table
{
	//...

	public function validationDefault(Validator $validator): Validator
    {
        $validator
            ->integer('id')
            ->allowEmptyString('id', null, 'create');

        $validator
            ->scalar('title')
            ->requirePresence('title', 'create')
            ->minLength('title', 2, '2文字以上で入力してください。')
            ->maxLength('title', 150, '150文字以下で入力してください。')
            ->notEmptyString('title', '必ず入力してください。');


        $listValid = new Validator();
        $listValid
            ->scalar('name')
            ->minLength('name', 2, '2文字以上で入力してください。')
            ->maxLength('name', 20, '20文字以下で入力してください。')
            ->notEmptyString('name', '必ず入力してください。')
            ->scalar('age')
            ->maxLength('age', 2, '2桁以下で入力してください。')
            ->numeric('age', '整数で入力してください')
            ->notEmptyString('age', '必ず入力してください。');

        $validator->addNestedMany('list', $listValid);

        return $validator;
    }
}

listカラムにnameageという項目を複数バリデーションします。
この場合はaddNestedManyを使用して追加します。

コントローラー

コントローラーです。
jsonで保存する為にlistにはjson_encodeで変換するというのが重要なところでありますが、その他にもいろいろと処理が必要になります。
もっとスマートな方法があると思いますが、とりあえずこんな感じにおちつきました。

addもeditもちょっと違うだけでほぼ同じです。

src/Controller/ItemsController.php

<?php
declare(strict_types=1);

namespace App\Controller;

use App\Model\Entity\Item;

class ItemsController extends AppController
{
	//...

	public function add()
	{
		$item = $this->Items->newEmptyEntity();

		if ($this->request->is('post')) {
			$requestData = $this->request->getData();

			$item = $this->Items->patchEntity($item, $requestData);

			if ($item->hasErrors()) {
				// setするとエラー消えるので先に保存しておく
				$errors = $item->getErrors();
				$item->set('list', $item->getInvalidField('list'))
					->setErrors($errors);

				$this->Flash->error('データの保存に失敗しました。');
			} else {
				// 配列のindexを連番後、jsonに変換
				$item->set('list', json_encode(array_values($requestData['list'])));

				if ($this->Items->save($item)) {
					$this->Flash->success('データの保存に成功しました。');

					return $this->redirect(['action' => 'index']);
				}
				$this->Flash->error('データの保存に失敗しました。');
			}
		}
		$this->set(compact('item'));

	}

	public function edit($id = null)
	{
		$item = $this->Items->get($id);

		if ($this->request->is(['patch', 'post', 'put'])) {
			$requestData = $this->request->getData();

			$item = $this->Items->patchEntity($item, $requestData);

			if ($item->hasErrors()) {
				$errors = $item->getErrors();

				$item->set('list', $item->getInvalidField('list'))
					->setErrors($errors);

				$this->Flash->error('データの更新に失敗しました。');
			} else {
				$item->set('list', json_encode(array_values($requestData['list'])));

				if ($this->Items->save($item)) {
					$this->Flash->success('データの更新に成功しました。');

					return $this->redirect(['action' => 'edit', $id]);
				}
				$this->Flash->error('データの更新に失敗しました。');
			}
		} else {
			$item->set('list', json_decode($item->list, true));
		}

		$this->set(compact('item'));
	}
	// ...
}

ビュー

ビューもaddとeditはほとんど同じです。
jsonごとJavaScriptに渡した方がスマートですが、今回はJavaScriptの作りは最小限にしたったので、PHPで展開してます。

templates/Items/add.php

<div class="row">
	<div class="column">
		<div class="content">
			<?= $this->Form->create($item) ?>
				<fieldset>
				<legend>登録</legend>
				<?= $this->Form->control('title', [
					'label' => 'タイトル'
				]) ?>
				<div class="multifield">
					<table class="item-wrap">
						<tr data-index="0">
							<th>お名前</th>
							<td><?= $this->Form->control('list.0.name', [
								'class' => 'input-name',
								'label' => false
							]); ?></td>
							<th>年齢</th>
							<td><?= $this->Form->control('list.0.age', [
								'class' => 'input-name',
								'label' => false
							]); ?></td>
							<td><div class="button remove-btn">削除</div></td>
						</tr>
					</table>
					<div class="button add-btn">追加</div>
				</div>

				<?= $this->Form->button('保存') ?>
				</fieldset>
			<?= $this->Form->end() ?>
		</div>
	</div>
</div>

templates/Items/edit.php

<div class="row">
	<div class="column">
		<div class="content">
			<?= $this->Form->create($item) ?>
				<fieldset>
				<legend>編集</legend>
				<?= $this->Form->control('title', [
					'label' => 'タイトル'
				]) ?>
				<div class="multifield">
					<table class="item-wrap">
					<?php foreach($item->list as $key => $value): ?>
						<tr data-index="<?= $key ?>">
							<th>お名前</th>
							<td><?= $this->Form->control('list.'.$key.'.name', [
								'class' => 'input-name',
								'label' => false
							]); ?></td>
							<th>年齢</th>
							<td><?= $this->Form->control('list.'.$key.'.age', [
								'class' => 'input-name',
								'label' => false
							]); ?></td>
							<td><div class="button remove-btn">削除</div></td>
						</tr>
					<?php endforeach; ?>
					</table>
					<div class="button add-btn">追加</div>
				</div>
				<?= $this->Form->button('保存') ?>
				</fieldset>
			<?= $this->Form->end() ?>
		</div>
	</div>
</div>

JavaScript

レイアウトでjQueryと新しく作るJSを読み込みます。

templates/layout/default.php

<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<?= $this->Html->script('script.js') ?>

最低数とか最大数とかできてないですが、とりあえず動くレベルです。

webroot/js/script.js

(function($) {
$(function () {
	const $wrap = $('.multifield');
	const $itemWrap = $('.item-wrap', $wrap);

	let index = $('tr', $wrap).last().data('index');

	const templete = (index) => {
		return `<tr data-index="${index}">
			<th>お名前</th>
			<td><div class="input text"><input type="text" name="list[${index}][name]" class="input-name" id="list-${index}-name" value=""></div></td>
			<th>年齢</th>
			<td><div class="input text"><input type="text" name="list[${index}][age]" class="input-name" id="list-${index}-age" value=""></div></td>
			<td><div class="button remove-btn">削除</div></td>
		</tr>`;
	}

	$wrap.on('click', '.add-btn', function() {
		index++;
		$itemWrap.append(templete(index));
	});

	$wrap.on('click', '.remove-btn', function() {
		$(this).parent().parent().remove();
	});
});
})(jQuery);