Dependent fields
This form loads cities for the selected country and rejects a city from another country.
Complete installation first. Copy the files below to the indicated application locations.
Create and seed the cities table using the schema tab or your application's normal migrations and data.
Files
php
<?php
declare(strict_types=1);
namespace App\Island;
use App\Model\Table\CitiesTable;
use Cake\ORM\Locator\LocatorAwareTrait;
use Interact\Attribute\Action;
use Interact\Attribute\State;
use Interact\Island;
/**
* Provides country-dependent city choices and validates the selected membership.
*/
class DependentSelectsIsland extends Island
{
use LocatorAwareTrait;
#[State(writable: true)]
public string $country = 'BE';
#[State(writable: true)]
public ?int $cityId = null;
#[State]
public bool $confirmed = false;
/**
* Clear the selected city when its owning country changes.
*
* @param list<string> $paths Reset a stale city when its country changes.
*/
public function updated(array $paths): void
{
$this->confirmed = false;
if (in_array('country', $paths, true)) {
$this->cityId = null;
}
}
/**
* Recheck submitted membership rather than trusting the browser's rendered options.
*/
#[Action]
public function confirm(): void
{
if (
!in_array($this->country, ['BE', 'NL'], true)
|| $this->cityId === null
|| !$this->cities()->find('selection', country: $this->country, cityId: $this->cityId)->count()
) {
$this->setErrors(['cityId' => ['Choose a city in the selected country.']]);
return;
}
$this->confirmed = true;
}
/**
* Load allowed city choices for the current country through the model finder.
*
* @return array<string, mixed> Model-provided choices for the current country.
*/
public function viewData(): array
{
return ['cities' => $this->cities()->find('inCountry', country: $this->country)->all()];
}
/**
* Resolve the application's normal CakePHP model through its Table locator.
*/
protected function cities(): CitiesTable
{
$table = $this->fetchTable('Cities');
assert($table instanceof CitiesTable, 'The configured collaborator must satisfy its declared contract.');
return $table;
}
}php
<?php
declare(strict_types=1);
/**
* templates/island/dependent_selects.php
*
* @var \Interact\View\TemplateView $this
* @var string $country
* @var int|null $cityId
* @var bool $confirmed
* @var \Cake\Collection\CollectionInterface $cities
*/
?>
<?= $this->Form->create(null, ['url' => false, 'x-interact:submit' => '$server.call("confirm")']) ?>
<?= $this->Form->control('country', [
'options' => ['BE' => 'Belgium', 'NL' => 'Netherlands'],
'value' => $country,
'x-interact:model.change' => 'country',
]) ?>
<?= $this->Form->control('cityId', [
'label' => 'City',
'options' => $cities->combine('id', 'name')->toArray(),
'empty' => 'Choose a city',
'value' => $cityId,
'x-interact:model' => 'cityId',
]) ?>
<ul role="alert">
<template x-for="(message, index) in ($server.errors.cityId || [])" :key="index">
<li x-text="message"></li>
</template>
</ul>
<?= $this->Form->button('Confirm') ?>
<?= $this->Form->end() ?>
<?php if ($confirmed): ?>
<p role="status">Selection confirmed.</p>
<?php endif; ?>
<p role="alert" x-text="$server.error ? $server.error.message : ''"></p>php
<?php
declare(strict_types=1);
namespace App\Model\Table;
use Cake\ORM\Query\SelectQuery;
use Cake\ORM\Table;
/**
* Encapsulates country-dependent city choices and membership checks used by the selectors.
*/
class CitiesTable extends Table
{
/**
* Restrict and order the available cities by country.
*
* @param \Cake\ORM\Query\SelectQuery<\Cake\ORM\Entity> $query
*
* @return \Cake\ORM\Query\SelectQuery<\Cake\ORM\Entity>
*/
public function findInCountry(SelectQuery $query, string $country): SelectQuery
{
return $query->where(['Cities.country' => $country])->orderByAsc('Cities.name');
}
/**
* Check a selected city within the same country-scoped query.
*
* @param \Cake\ORM\Query\SelectQuery<\Cake\ORM\Entity> $query
*
* @return \Cake\ORM\Query\SelectQuery<\Cake\ORM\Entity>
*/
public function findSelection(SelectQuery $query, string $country, int $cityId): SelectQuery
{
return $query->find('inCountry', country: $country)->where(['Cities.id' => $cityId]);
}
}sql
CREATE TABLE cities (id INTEGER PRIMARY KEY, country VARCHAR(2) NOT NULL, name VARCHAR(100) NOT NULL);
INSERT INTO cities (id, country, name) VALUES (1, 'BE', 'Brussels'), (2, 'BE', 'Ghent'), (3, 'NL', 'Amsterdam'), (4, 'NL', 'Rotterdam');php
<?= $this->Interact->render('DependentSelects') ?>Behavior
Country changes synchronize immediately. updated() clears the previous city; viewData() loads new choices through inCountry. The confirmation action checks membership through selection, so forged combinations also fail.
Rendered options are a convenience. The action remains responsible for checking the submitted values. Apply the same pattern to conditional fields and validate required values even when the browser previously hid a control.