Dependent country/city form

flocms-reactive 0.5.1 · October 16, 2026 View as Markdown Edit on GitHub

The runnable form at /en/demo/form demonstrates changing a country, loading only its cities, resetting a previous city, displaying field errors, and saving an address that survives a reload. Kurdish and Arabic routes use RTL layouts.

Start the development example from a package Git checkout:

composer install
php -S 127.0.0.1:8080 examples/products/router.php

The implementation is in examples/forms/LocationForm.php, location.html, schema.php, and lang/{en,ku,ar}.php. The demo router initializes a private SQLite file for each session. It supplies fake login and a fixed example signing key; use your application's real login, APP_KEY, database and migrations when adapting the component. Demo lookup labels use fixed IDs for clarity.

State and dependent reset

Use explicitly declared scalar state for the draft. IDs use 0 to represent an empty selection, so they remain non-null integers:

#[State]
public string $name = '';
#[State(rules: 'integer|min:0|max:1000000')]
public int $countryId = 0;
#[State(rules: 'integer|min:0|max:1000000')]
public int $cityId = 0;

public function updated(string $property): void
{
    if ($property === 'countryId') $this->cityId = 0;
}

The country select uses data-rx-bind="countryId" data-rx-reset="cityId:0". The browser immediately clears the related select; the PHP hook enforces the same reset when browser behavior is bypassed. All accepted fields are applied before hooks run, in declaration order, so a submitted city cannot overwrite the country hook's reset. A single request changing both country and city resets the city; select the city after the country update completes.

The options query filters by the current country. A country with no cities disables the city select and displays an explanation. This query limits the UI; validation must still verify the relationship against current database records.

Draft validation and save validation

validate() checks non-empty selections on every update (the default; a component can return false from validatesOnUpdate() to check only before actions). Incomplete drafts are permitted. A forged city, unknown country, or removed lookup returns 422. The declared field rules still reject malformed scalar values before hooks run.

Required and business checks belong to the explicit save action. Use Core's Validator or throw its ValidationException with translated field messages:

#[Action(permission: 'locations.edit')]
public function save(): void
{
    App::db()->transaction(function (): void {
        // Reload the current record/lookups and check ownership/permissions.
        Validator::validate(['name' => trim($this->name)], [
            'name' => 'required|string|max:100',
        ]);
        // Verify that cityId belongs to countryId before writing.
        // Persist only validated values using parameter-bound queries.
    });
}

Kernel catches ValidationException from the action and returns rendered field errors with 422 and a fresh signed snapshot. Accepted draft values remain available for correction. Validation inside a transaction rolls back writes; ordinary database failures propagate rather than appearing as success or field validation. A transaction cannot undo email delivery or other external effects.

Keep draft strings within the runtime's 8KB scalar limit. The example delays required/max-name business checks until save, allowing incomplete and invalid names to remain visible and editable. A malformed type or a failed State::rules check still retains the last accepted batch, as in the table example.

Permissions, ownership and persistence

authorize() requires locations.view on mount and every update. The save action independently requires locations.edit, including after permissions change. Users with view permission alone see a disabled save button; server authorization remains authoritative if that button is bypassed.

The owner ID comes from the authenticated session on each request and is never a writable state field. Address reads and writes are scoped to that identity. mount() reads the existing address for reload persistence. Components themselves are recreated from signed snapshots; the application database stores the address. The example maintains one address per owner and updates it on later saves.

For other resources, load and authorize the specific record as well. Tenant and record scopes must cover both option queries and mutations. For external effects or concurrent requests outside this browser component, add application-level idempotency and conflict handling appropriate to the operation.

Accessible template and browser behavior

The template associates labels with stable input IDs and uses aria-invalid and aria-describedby for field errors and help text. Messages are escaped by FloCMS templates. Error containers use role="alert"; the success message uses role="status". This example mounts one location form per page; give controls unique IDs when adapting it for multiple instances.

<form data-rx-submit="save" novalidate> submits the registered action. novalidate lets server errors demonstrate a consistent localized validation flow. Binding tracks input changes, and submit also reads current controls to capture autofill that did not emit an input event. Related resets update controls before that collection. Submit buttons are temporarily disabled during updates, and an already disabled button remains disabled afterwards.

Requests remain serialized. Newer country edits suppress older options, focus survives DOM updates, and duplicate submits while busy do not replay a running save. Client serialization is not a server-side idempotency guarantee. Forms needing operation without JavaScript should also provide an ordinary POST route.

The PHP tests cover relationship validation, reset ordering, required errors, draft escaping, fresh snapshots, persistence, user isolation, revocation and transaction rollback. Browser tests cover selects, autofill, save errors/recovery, reloads, slow updates, duplicate submits, permissions, mobile layouts and RTL.

Esc