Reusable child inputs
This form binds a resource picker's selected ID and a date range to its own writable draft. Changing the category refreshes the picker while preserving its unrelated notes. Child PHP changes and parent resets update the shared values.
Complete installation first. The runnable demo is at /composition; follow the demo setup. It uses the seeded Resources Table and finders already supplied by the demo. Copying this example into an application requires adapting TestApp namespaces to App and supplying those resources and finders, or adapting the queries to your own model.
Parent form
The parent owns the category and draft. review() checks the selected resource's category membership on the server. It demonstrates validation without saving a record; an application supplies its own persistence action.
<?php
declare(strict_types=1);
namespace TestApp\Island\Composition;
use Cake\ORM\Locator\LocatorAwareTrait;
use Interact\Attribute\Action;
use Interact\Attribute\Prop;
use Interact\Attribute\State;
use Interact\Island;
use TestApp\Model\Table\ResourcesTable;
/**
* Owns a resource selection draft assembled from independently interactive child inputs.
* The review action validates membership explicitly; synchronization never saves a record.
*/
class OrderIsland extends Island
{
use LocatorAwareTrait;
protected ResourcesTable $resources;
#[Prop]
public string $pickerLoading = 'eager';
#[State(writable: true)]
public string $category = 'cakephp';
/**
* @var array{resourceId: int|null, startsAt: string, endsAt: string} Parent-owned form draft.
*/
#[State(writable: true)]
public array $data = ['resourceId' => null, 'startsAt' => '', 'endsAt' => ''];
#[State]
public string $reviewed = '';
/**
* Assign the request-local Table before authorization and state restoration.
*/
public function initialize(): void
{
$this->resources = $this->fetchTable(ResourcesTable::class);
}
/**
* Check current resource membership rather than trusting the child input or its snapshot.
*/
#[Action]
public function review(): void
{
if (!is_int($this->data['resourceId'] ?? null)) {
$this->setErrors(['data.resourceId' => ['Choose a resource from the current category.']]);
return;
}
$resource = $this->resources
->find('filtered', category: $this->category)
->where(['Resources.id' => $this->data['resourceId']])
->first();
if ($resource === null) {
$this->setErrors(['data.resourceId' => ['Choose a resource from the current category.']]);
return;
}
$this->reviewed = (string)$resource->get('title');
}
/**
* Demonstrate accepted parent PHP changes flowing back through model bindings.
*/
#[Action(render: false)]
public function clear(): void
{
$this->data = ['resourceId' => null, 'startsAt' => '', 'endsAt' => ''];
$this->reviewed = '';
}
/**
* Supply existing application choices without transporting entities or queries.
*
* @return array{categories: array<string, string>}
*/
public function viewData(): array
{
return ['categories' => ResourcesTable::CATEGORIES];
}
}<?php
declare(strict_types=1);
/**
* @var \Interact\View\TemplateView $this
* @var string $category
* @var string $pickerLoading
* @var array<string, string> $categories
*/
?>
<section class="composition-order" aria-label="Resource order">
<?= $this->Form->create(null, [
'url' => false,
'class' => 'composition-layout',
'x-interact:submit' => '$server.call("review")',
]) ?>
<div class="side-stack">
<div class="demo-card composition-category">
<p class="eyebrow">01 · Narrow your choices</p>
<h2>Resource order</h2>
<p class="card-description">Choose a category to update the resource picker below.</p>
<?= $this->Form->control('category', [
'label' => 'Resource category',
'options' => $categories,
'value' => $category,
'x-interact:model.change' => 'category',
]) ?>
</div>
<?= $this->Interact->render(
'Composition/Picker',
props: ['category' => $category],
key: 'resource-picker',
loading: $pickerLoading,
model: 'data.resourceId',
) ?>
</div>
<div class="side-stack">
<?= $this->Interact->render(
'Composition/DateRange',
key: 'date-range',
model: ['start' => 'data.startsAt', 'end' => 'data.endsAt'],
) ?>
<section class="demo-card composition-summary" aria-label="Selection summary">
<p class="eyebrow">04 · Review your selection</p>
<h2>Your reading plan</h2>
<p class="card-description">Your choices appear here as you make them.</p>
<dl class="composition-values">
<div>
<dt>Resource ID</dt>
<dd
data-selection
x-text="$server.state.data.resourceId === null ? 'None' : $server.state.data.resourceId"
></dd>
</div>
<div>
<dt>Date range</dt>
<dd data-range x-text="$server.state.data.startsAt + ' / ' + $server.state.data.endsAt"></dd>
</div>
</dl>
<div class="button-row">
<?= $this->Form->button('Review selection', ['type' => 'submit', 'class' => 'button-primary']) ?>
<?= $this->Form->button('Clear selection', [
'type' => 'button',
'class' => 'button-text',
'@click' => '$server.call("clear")',
]) ?>
</div>
<p class="success-message" role="status" x-text="$server.state.reviewed"></p>
<p
class="field-error"
role="alert"
x-text="$server.errors['data.resourceId'] ? $server.errors['data.resourceId'][0] : ''"
></p>
<p class="field-hint">Review checks your selection. This demo does not save a reading plan.</p>
</section>
</div>
<?= $this->Form->end() ?>
</section><?= $this->Interact->render('Composition/Order') ?>Resource picker
The picker receives a reactive category, owns its search and notes, and exposes only value to its parent's data.resourceId binding. Its updated() hook clears the selection when the category changes. choose() checks membership before accepting an ID. The parent validates independently when reviewing the draft.
<?php
declare(strict_types=1);
namespace TestApp\Island\Composition;
use Cake\ORM\Locator\LocatorAwareTrait;
use Interact\Attribute\Action;
use Interact\Attribute\Prop;
use Interact\Attribute\State;
use Interact\Island;
use Interact\UpdatedPaths;
use TestApp\Model\Table\ResourcesTable;
/**
* Exposes one modelable selection while searching through application-owned resource finders.
* Category changes arrive only from verified parent renders; selection membership is checked in PHP.
*/
class PickerIsland extends Island
{
use LocatorAwareTrait;
protected ResourcesTable $resources;
#[Prop(reactive: true)]
public string $category = 'cakephp';
#[State(writable: true, modelable: true)]
public ?int $value = null;
#[State(writable: true)]
public string $search = '';
#[State(writable: true)]
public string $notes = '';
/**
* Assign the request-local Table before authorization and state restoration.
*/
public function initialize(): void
{
$this->resources = $this->fetchTable(ResourcesTable::class);
}
/**
* Clear a selection whose category changed without replacing unrelated child drafts.
*
* @param UpdatedPaths $paths Accepted prop and state changes.
*/
public function updated(UpdatedPaths $paths): void
{
if ($paths->has('category')) {
$this->value = null;
}
}
/**
* Accept only a resource from the currently authorized category scope.
*/
#[Action]
public function choose(int $id): void
{
$resource = $this->resources
->find('filtered', category: $this->category)
->where(['Resources.id' => $id])
->first();
if ($resource === null) {
$this->setErrors(['value' => ['Choose a resource from the current category.']]);
return;
}
$this->value = $id;
}
/**
* Fetch a short search result list after the normal authorization and update lifecycle.
*
* @return array{resources: \Cake\Datasource\ResultSetInterface<array-key, \Cake\Datasource\EntityInterface>}
*/
public function viewData(): array
{
return [
'resources' => $this->resources
->find('listing', search: $this->search, category: $this->category)
->limit(8)
->all(),
];
}
}<?php
declare(strict_types=1);
use function Cake\Core\h;
/**
* @var \Interact\View\TemplateView $this
* @var string $search
* @var string $notes
* @var string $category
* @var \Cake\Datasource\ResultSetInterface<array-key, \Cake\Datasource\EntityInterface> $resources
*/
?>
<section class="demo-card" aria-label="Resource picker">
<div class="card-heading">
<div>
<p class="eyebrow">02 · Pick a resource</p>
<h2>Choose something to read</h2>
</div>
<span class="topic-tag" data-category><?= (string)h($category) ?></span>
</div>
<?= $this->Form->control('resource_search', [
'label' => 'Find resources',
'placeholder' => 'Search within this category…',
'type' => 'search',
'value' => $search,
'x-interact:model.live' => 'search',
]) ?>
<ul class="composition-choices">
<?php foreach ($resources as $resource): ?>
<li>
<button
type="button"
:aria-pressed="$server.state.value === <?= (int)$resource->get('id') ?>"
@click="$server.call('choose', <?= (int)$resource->get('id') ?>)"
>
<span><?= (string)h((string)$resource->get('title')) ?></span>
<span class="composition-choice-mark" aria-hidden="true">✓</span>
</button>
</li>
<?php endforeach; ?>
</ul>
<?php if ($resources->count() === 0): ?>
<p class="empty-state">No resources match your search. Try another title or category.</p>
<?php endif; ?>
<?= $this->Form->control('picker_notes', [
'label' => 'Picker notes',
'placeholder' => 'What caught your interest?',
'value' => $notes,
'x-interact:model' => 'notes',
]) ?>
<p class="field-hint">Your notes stay here when you change categories.</p>
<p class="field-error" role="alert" x-text="$server.errors.value ? $server.errors.value[0] : ''"></p>
</section><?php
declare(strict_types=1);
namespace TestApp\Model\Table;
use Cake\Database\Expression\FunctionExpression;
use Cake\Database\Expression\QueryExpression;
use Cake\ORM\Entity;
use Cake\ORM\Query\SelectQuery;
use Cake\ORM\Table;
use InvalidArgumentException;
/**
* Composes resource-library searches, allowed filters, and deterministic result ordering.
*
* Matching uses bound SQLite substring expressions so punctuation and SQL wildcard characters
* remain literal input. Finders leave counting, pagination, and execution to their caller.
*/
class ResourcesTable extends Table
{
/**
* Public category choices shared by the fixture library and its search controls.
*/
public const array CATEGORIES = [
'cakephp' => 'CakePHP',
'alpine' => 'Alpine',
'typescript' => 'TypeScript',
'forms' => 'Forms',
'security' => 'Security',
'testing' => 'Testing',
'performance' => 'Performance',
'accessibility' => 'Accessibility',
];
/**
* Available difficulty filters for the seeded library.
*/
public const array LEVELS = ['beginner' => 'Beginner', 'intermediate' => 'Intermediate', 'advanced' => 'Advanced'];
/**
* Map public sorting choices to model-owned columns and directions.
*/
protected const array ORDERING = [
'title' => ['Resources.title' => 'ASC'],
'title-desc' => ['Resources.title' => 'DESC'],
'shortest' => ['Resources.minutes' => 'ASC'],
'longest' => ['Resources.minutes' => 'DESC'],
];
/**
* Use normal CakePHP entities for query results.
*/
public function initialize(array $config): void
{
parent::initialize($config);
$this->setEntityClass(Entity::class);
}
/**
* Match every whitespace-separated word across titles, summaries, and category names.
*
* @param \Cake\ORM\Query\SelectQuery<\Cake\ORM\Entity> $query
*
* @return \Cake\ORM\Query\SelectQuery<\Cake\ORM\Entity>
*/
public function findMatching(SelectQuery $query, string $search = ''): SelectQuery
{
if (mb_strlen($search) > 200) {
throw new InvalidArgumentException('Search with up to 200 characters.');
}
$terms = preg_split('/\s+/u', mb_strtolower(trim($search)), flags: PREG_SPLIT_NO_EMPTY);
if ($terms === false) {
throw new InvalidArgumentException('Enter a valid search term.');
}
foreach ($terms as $term) {
$matches = new QueryExpression([], [], 'OR');
foreach (['Resources.title', 'Resources.summary', 'Resources.category'] as $field) {
$lower = new FunctionExpression('LOWER', [$field => 'identifier']);
$position = new FunctionExpression('INSTR', [$lower, $term]);
$matches->gt($position, 0, 'integer');
}
$query->where($matches);
}
return $query;
}
/**
* Apply allowed category and difficulty choices without accepting arbitrary field names.
*
* @param \Cake\ORM\Query\SelectQuery<\Cake\ORM\Entity> $query
*
* @return \Cake\ORM\Query\SelectQuery<\Cake\ORM\Entity>
*/
public function findFiltered(SelectQuery $query, string $category = '', string $level = ''): SelectQuery
{
if ($category !== '' && !isset(self::CATEGORIES[$category])) {
throw new InvalidArgumentException('Choose an available category.');
}
if ($level !== '' && !isset(self::LEVELS[$level])) {
throw new InvalidArgumentException('Choose an available difficulty.');
}
if ($category !== '') {
$query->where(['Resources.category' => $category]);
}
if ($level !== '') {
$query->where(['Resources.level' => $level]);
}
return $query;
}
/**
* Compose the library listing with a stable ID tie-breaker across equal titles or durations.
*
* @param \Cake\ORM\Query\SelectQuery<\Cake\ORM\Entity> $query
*
* @return \Cake\ORM\Query\SelectQuery<\Cake\ORM\Entity>
*/
public function findListing(
SelectQuery $query,
string $search = '',
string $category = '',
string $level = '',
string $sort = 'title',
): SelectQuery {
if (!isset(self::ORDERING[$sort])) {
throw new InvalidArgumentException('Choose an available sort order.');
}
return $query->find('matching', search: $search)
->find('filtered', category: $category, level: $level)
->orderBy(self::ORDERING[$sort])
->orderByAsc('Resources.id');
}
}Multiple bound values
The date input exposes start and end. The parent maps them to data.startsAt and data.endsAt. Editing updates the parent's draft immediately; the renderless preset() action publishes both PHP values through the same bindings. The parent's clear() action sends cleared values back to both children.
<?php
declare(strict_types=1);
namespace TestApp\Island\Composition;
use Interact\Attribute\Action;
use Interact\Attribute\State;
use Interact\Island;
/**
* Supplies two reusable date values independently bound to the owning form's draft.
* The preset action demonstrates PHP changes reaching both parent paths without HTML replacement.
*/
class DateRangeIsland extends Island
{
#[State(writable: true, modelable: true)]
public string $start = '';
#[State(writable: true, modelable: true)]
public string $end = '';
/**
* Publish two PHP-owned changes through the same model contract as ordinary edits.
*/
#[Action(render: false)]
public function preset(): void
{
$this->start = '2026-01-01';
$this->end = '2026-01-31';
}
}<?php
declare(strict_types=1);
/**
* @var \Interact\View\TemplateView $this
* @var string $start
* @var string $end
*/
?>
<section class="demo-card" aria-label="Date range">
<p class="eyebrow">03 · Make time to read</p>
<h2>Reading dates</h2>
<p class="card-description">Set a date range, or start with a suggested month.</p>
<div class="composition-dates">
<?= $this->Form->control('reading_start', [
'type' => 'date',
'label' => 'Start date',
'value' => $start,
'x-interact:model' => 'start',
]) ?>
<?= $this->Form->control('reading_end', [
'type' => 'date',
'label' => 'End date',
'value' => $end,
'x-interact:model' => 'end',
]) ?>
</div>
<button type="button" class="button-text" @click="$server.call('preset')">Use January</button>
</section>Bindings use deferred parent synchronization here. Changing the picker category uses an explicit .change binding on the parent control; child search requests remain independent. See nested islands for live, change, blur, and debounce options.
Deferred children
Open /composition?loading=manual to change the parent category before loading the picker. Parent input changes retain the placeholder; loading later uses the latest verified props. The same contracts apply to lazy and deferred initialization.