Skip to content

Article form ​

This form binds an article and its new comments to writable array $data. Native FormHelper names supply nested paths; the application patches a new entity, validates it, and explicitly saves its comments.

Complete installation first. Copy the files below to the indicated application locations. The demo uses the TestApp namespace; change it to App in the PHP classes and imports when copying into your own application.

Create the form_articles and comments tables with the schema tab or your normal migration. The example uses CakePHP's generic entities. An application with its own entities must allow access to the shown draft fields and comments association. No entity is transported. Run it in the demo at /forms to use the FormHelper example.

Files ​

php
<?php
declare(strict_types=1);

namespace TestApp\Island\Article;

use Cake\Datasource\EntityInterface;
use Cake\ORM\Locator\LocatorAwareTrait;
use Interact\Attribute\Action;
use Interact\Attribute\State;
use Interact\Island;
use TestApp\Model\Table\FormArticlesTable;

/**
 * Marshals an article and new comments while retaining failed entities for FormHelper.
 *
 * Save as src/Island/Article/FormIsland.php. Only explicit drafts cross requests; entities
 * and persistence remain application-owned and request-local.
 */
class FormIsland extends Island
{
    use LocatorAwareTrait;

    /**
     * @var array{title: string, body: string, published: bool, topics: list<string>, comments: list<array{body: string, approved: bool, id?: mixed}>, id?: mixed}
     */
    #[State(writable: true)]
    public array $data = [
        'title' => '',
        'body' => '',
        'published' => false,
        'topics' => [],
        'comments' => [['body' => '', 'approved' => false]],
    ];

    #[State]
    public bool $saved = false;

    protected ?EntityInterface $article = null;

    /**
     * Clear stale save feedback when the application receives another draft edit.
     *
     * @param list<string> $paths Accepted writable paths.
     */
    public function updated(array $paths): void
    {
        $this->saved = false;
    }

    /**
     * Add an application-defined blank row after capturing current form drafts.
     */
    #[Action]
    public function addComment(): void
    {
        $this->data['comments'][] = ['body' => '', 'approved' => false];
        $this->saved = false;
    }

    /**
     * Remove a draft row and keep native indexed names contiguous for marshalling.
     */
    #[Action]
    public function removeComment(int $index): void
    {
        if ($index >= 0 && $index < count($this->data['comments'])) {
            array_splice($this->data['comments'], $index, 1);
        }

        $this->saved = false;
    }

    /**
     * Patch only allowed fields and explicitly save the article and its new comments.
     */
    #[Action]
    public function save(): void
    {
        $this->saved = false;
        // This create-only form never accepts existing article or association IDs.
        // An editor must authorize every submitted association ID before marshalling.
        if (array_key_exists('id', $this->data)) {
            $this->setErrors(['_form' => ['Existing article IDs are not accepted.']]);

            return;
        }

        foreach ($this->data['comments'] as $comment) {
            if (!array_key_exists('id', $comment)) {
                continue;
            }

            $this->setErrors(['_form' => ['Existing comment IDs are not accepted.']]);

            return;
        }

        $articles = $this->articles();
        $this->article = $articles->patchEntity($articles->newEmptyEntity(), $this->data, [
            'fields' => ['title', 'body', 'published', 'topics', 'comments'],
            'associated' => ['Comments' => ['fields' => ['body', 'approved']]],
        ]);
        if (!$articles->save($this->article, ['associated' => ['Comments']])) {
            $this->setErrors($this->article->getErrors() ?: ['_form' => ['Unable to save the article.']]);

            return;
        }

        $this->saved = true;
        $this->dispatch('article-saved', ['id' => $this->article->get('id')]);
    }

    /**
     * Supply the failed entity or the current draft as FormHelper's entity context.
     *
     * Draft fields are assigned for presentation without marshalling associations
     * or running validation. The save action owns marshalling and persistence.
     *
     * @return array{article: EntityInterface}
     */
    public function viewData(): array
    {
        return ['article' => $this->article ?? $this->articles()->newEmptyEntity()->patch($this->data)];
    }

    /**
     * Resolve the application's normal Table through CakePHP's locator.
     */
    protected function articles(): FormArticlesTable
    {
        $table = $this->fetchTable('FormArticles');
        assert($table instanceof FormArticlesTable, 'Configure the application FormArticles table.');

        return $table;
    }
}
php
<?php
declare(strict_types=1);
use function Cake\Core\h;

/**
 * templates/island/Article/form.php
 *
 * @var \Interact\View\TemplateView $this
 * @var \Cake\Datasource\EntityInterface $article
 * @var array{title: string, body: string, published: bool, topics: list<string>, comments: list<array{body: string, approved: bool}>} $data
 * @var bool $saved
 * @var array<string, list<string>> $errors
 */
$this->Form->setTemplates([
    'error' => '<div class="error-message field-error" id="{{id}}" role="alert">{{content}}</div>',
]);
?>
<?= $this->Form->create($article, [
    'url' => false,
    'id' => 'article-draft',
    'x-interact:model' => 'data',
    'x-interact:submit' => '$server.call("save")',
]) ?>
    <?= $this->Form->control('title') ?>
    <?= $this->Form->control('body', ['type' => 'textarea', 'rows' => 5]) ?>
    <?= $this->Form->control('published', [
        'type' => 'checkbox',
        'label' => ['text' => 'Published', 'class' => 'check-option'],
    ]) ?>
    <fieldset class="choice-group">
        <legend>Topics</legend>
        <?= $this->Form->multiCheckbox('topics', ['cakephp' => 'CakePHP', 'php' => 'PHP'], [
            'label' => ['class' => 'check-option'],
        ]) ?>
    </fieldset>
    <div class="article-rows">
        <?php foreach (array_keys($data['comments']) as $index): ?>
            <fieldset class="article-editor">
                <legend class="row-id">Comment <?= (int)$index + 1 ?></legend>
                <?= $this->Form->control('comments.' . $index . '.body', [
                    'label' => 'Comment ' . ($index + 1) . ' text',
                ]) ?>
                <?= $this->Form->control('comments.' . $index . '.approved', [
                    'type' => 'checkbox',
                    'label' => ['text' => 'Approve comment ' . ($index + 1), 'class' => 'check-option'],
                ]) ?>
                <?= $this->Form->button('Remove comment ' . ($index + 1), [
                    'type' => 'button',
                    'class' => 'button-text',
                    '@click' => '$server.call("removeComment", ' . $index . ')',
                ]) ?>
            </fieldset>
        <?php endforeach; ?>
    </div>
    <div class="form-footer">
        <?= $this->Form->button('Add comment', [
            'type' => 'button',
            'class' => 'button-text',
            '@click' => '$server.call("addComment")',
        ]) ?>
    </div>
    <?php foreach ($errors['_form'] ?? [] as $message): ?>
        <p role="alert" class="field-error"><?= (string)h($message) ?></p>
    <?php endforeach; ?>
    <div class="form-footer">
        <?= $this->Form->button('Save article', ['class' => 'button-primary']) ?>
        <span class="quiet" x-show="$server.loading('save')" x-cloak>Saving…</span>
    </div>
<?= $this->Form->end() ?>
<?php if ($saved): ?>
    <p role="status" class="success-message" x-show="!$server.dirty">Article saved.</p>
<?php endif; ?>
<p role="alert" class="request-error" x-text="$server.error ? $server.error.message : ''"></p>
php
<?php
declare(strict_types=1);

namespace TestApp\Model\Table;

use Cake\ORM\Entity;
use Cake\ORM\Table;
use Cake\Validation\Validator;

/**
 * Validates article drafts and configures native associated comment marshalling.
 *
 * Save as src/Model/Table/FormArticlesTable.php.
 */
class FormArticlesTable extends Table
{
    /**
     * Configure generic entities, JSON topics, and the explicitly saved association.
     */
    public function initialize(array $config): void
    {
        parent::initialize($config);
        $this->setEntityClass(Entity::class);
        $this->getSchema()->setColumnType('topics', 'json');
        $this->hasMany('Comments', ['className' => CommentsTable::class])->setForeignKey('article_id');
    }

    /**
     * Require a title that fits the database column.
     */
    public function validationDefault(Validator $validator): Validator
    {
        return $validator->requirePresence('title', 'create')
            ->notEmptyString('title', 'Enter a title.')
            ->maxLength('title', 120, 'Use up to 120 characters.')
            ->scalar('body')
            ->allowEmptyString('body')
            ->boolean('published')
            ->allowEmptyArray('topics')
            ->multipleOptions('topics', ['in' => ['cakephp', 'php']]);
    }
}
php
<?php
declare(strict_types=1);

namespace TestApp\Model\Table;

use Cake\ORM\Entity;
use Cake\ORM\Table;
use Cake\Validation\Validator;

/**
 * Validates new comment drafts through CakePHP's associated marshaller.
 *
 * Save as src/Model/Table/CommentsTable.php. Resource authorization belongs to the
 * application before existing association IDs are accepted.
 */
class CommentsTable extends Table
{
    /**
     * Configure native generic entities for this focused example.
     */
    public function initialize(array $config): void
    {
        parent::initialize($config);
        $this->setEntityClass(Entity::class);
    }

    /**
     * Require comment text and accept only boolean approval flags.
     */
    public function validationDefault(Validator $validator): Validator
    {
        return $validator->requirePresence('body', 'create')
            ->notEmptyString('body', 'Enter a comment.')
            ->maxLength('body', 500, 'Use up to 500 characters.')
            ->boolean('approved');
    }
}
sql
CREATE TABLE form_articles (
    id INTEGER PRIMARY KEY,
    title VARCHAR(120) NOT NULL,
    body TEXT NOT NULL DEFAULT '',
    published BOOLEAN NOT NULL DEFAULT 0,
    topics TEXT NOT NULL DEFAULT '[]'
);
CREATE TABLE comments (
    id INTEGER PRIMARY KEY,
    article_id INTEGER NOT NULL REFERENCES form_articles(id),
    body VARCHAR(500) NOT NULL,
    approved BOOLEAN NOT NULL DEFAULT 0
);
php
<?= $this->Interact->render('Article/Form') ?>

Behavior ​

x-interact:model="data" binds every eligible named control. FormHelper's comments.0.body produces name="comments[0][body]", which binds data.comments.0.body. The published and approval checkboxes are booleans; topics[] is a list. FormHelper's hidden fallbacks never overwrite these values.

addComment() and removeComment() capture current controls before changing the application draft. Removing a row reindexes its names. These actions do not save. Use stable application row keys when an editor needs identity across reordering.

An empty title, a title longer than 120 characters, or an empty comment fails Table validation.

FormHelper reads values from its entity context, so controls need no per-field value, checked, or val mappings. On ordinary renders, viewData() assigns the draft to a new request-local entity with patch(). That assignment performs no association loading, validation, or persistence. After a failed save, FormHelper receives the failed entity and its original validation errors, while $data preserves every submitted value. setErrors() supplies field feedback such as comments.0.body. The response retains the original draft rather than replacing it with a partially marshalled entity.

A valid save calls patchEntity() with explicit root and association fields, then save() with explicit Comments persistence. Topics use a native JSON column type; checkbox values use CakePHP's normal boolean marshalling. The action dispatches article-saved. See Events for a listing listener.

Existing records and association IDs ​

This public create-only example rejects article and comment IDs before marshalling. Model binding does not authorize submitted IDs, and hiding an ID or allowlisting editable fields does not grant access to that record. Existing-record editors must authorize the article on every request and independently authorize each supplied association ID before patchEntity().

Initialize an existing-record draft in mount() from explicitly selected fields. Keep association loading and access scopes in a named Table finder; execute it in the island after authorization. Reload the entity for saving on later requests, without overwriting the transported draft. Choose association persistence options and deletion behavior explicitly in the application.

The remaining mapping is application-owned: initial draft selection, patchable fields, association save options, and failed entity context for FormHelper. This example needs no entity transport or restoration layer. A future entity integration should be justified by repeated mapping in an existing-record editor, not by introducing automatic persistence here.