meraki / schema-html
Render a meraki/schema as an HTML form.
Requires
- php: ^8.4
- meraki/schema: ^1.13.0-alpha
- psr/http-message: ^1.1 || ^2.0
Requires (Dev)
- phpunit/phpunit: ^11.3
README
Render a meraki/schema as an HTML form.
Keeps HTML/form-rendering concerns out of the core schema domain. Reads only
meraki/schema's public API.
Usage
use Meraki\Schema\Facade; use Meraki\Schema\Html\FormRenderer; use Meraki\Schema\Html\FormOptions; $schema = new Facade('signup'); $schema->addNameField('full_name')->minLengthOf(1)->maxLengthOf(255); $schema->addEmailAddressField('email'); $schema->addBooleanField('subscribe')->makeOptional(); // Optional: configure the form + per-field UI $options = (new FormOptions())->postTo('/signup'); $options->configure('email')->label('Your email address'); $html = (new FormRenderer())->render($schema, $options);
Pass a validation result back in to surface inline error messages, against the individual fields that failed:
$result = $schema->validate($input); echo (new FormRenderer())->render($schema, $options, $result);
Form options
FormOptions is a fluent builder producing the array the renderer consumes:
postTo($url)/getFrom($url)— form method + action.configure($name)(orconfigureOptionsFor($name)) →FieldOptions:label(),hint(),renderAs(Renderer),renderAsDropdown(),renderAsTextarea(),readonly(),disabled(),hidden(),autocomplete(),labelOption($value, $label)(Enum), andconfigureFor()for composite sub-fields.
Autocomplete
Every field emits the semantic autocomplete token a browser needs to autofill
it — email for an email address, tel for a phone number, cc-number and
address-line1 for the relevant parts of a credit card or address — or nothing
at all where no token is meaningful. A Field\Text gets its token from the
composite it sits in, not from its own type.
autocomplete(false) emits autocomplete="off" for anything a browser should
not remember; passing a token string overrides the default outright:
$options->configure('password')->autocomplete('new-password'); $options->configure('one_time_code')->autocomplete(false);
Addresses
Field\Address gets a dedicated renderer that reads the countries the field
allows and asks commerceguys/addressing how they describe an address. The core
library deliberately holds none of this — what a country calls the thing in the
administrative_area box is presentation, useless to a JSON serializer — so
AddressVocabulary owns it here.
- Labels follow the country when exactly one is allowed: Australia gets "Suburb" and "State", Japan "Prefecture", the US "City" and "ZIP Code". With several allowed, the label generalises ("Administrative Area") and the hint carries the alternatives ("State or province").
- Dropdowns show names, submit codes — "Queensland" for
QLD, "Australia" forAU. The administrative area is only a dropdown when one country is allowed; with several, which subdivisions are valid depends on the country chosen, so it stays a text input and the server checks it. - Settled and unused parts are hidden. A single allowed country settles
country_code, which is hidden but still submitted so the address never serializes without it. Parts no allowed country uses are hidden too — Singapore has no administrative area, Hong Kong no postal code. patternandinputmodeare set on the postal code for a single allowed country.inputmode="numeric"only where the postal code really is digits-only: a numeric keyboard cannot type Canada'sK1A 0B1or an Irish eircode.
Renderer enumerates the allowed input renderers and validates them per field
type via Renderer::validFor($field).
Elements are built with a small internal Element class that maps native PHP
attribute values to HTML — true → bare attribute, false/null → omitted,
scalars → escaped name="value".
Request input
Input normalizes request data so it can be fed straight back to the schema,
smoothing over PHP's quirks:
- a present-but-unfilled field arrives as
''→ normalized tonull(presence is still recoverable viahas()); - a checked checkbox submits
'on'→ normalized totrue; - uploaded files are merged in by input name as
{ name, type, size }metadata (ready for theFilefield); - nested names like
price[amount]are accessible via chainedget(),ArrayAccess, or object access.
use Meraki\Schema\Html\Input; $input = Input::fromGlobals(); // $_POST + $_FILES $input = Input::fromPsrRequest($request); // PSR-7 parsed body + uploads $input = new Input([...]); // already-merged array (tests) $input->get('email'); // null if absent or empty $input->get('subscribe', false); // true when checked $input->get('price', [])->get('amount'); // chained nested access $input->has('email'); // presence (true even if empty) $result = $schema->validate($input->toArray());
Input is read-only.
Examples
Runnable scripts live in examples/ — each writes HTML to stdout,
so redirect it to a file to open in a browser:
render.php— a form, then the same form re-rendered with inline validation errors.booking.php— a repeatable collection of items.multi-step.php— the wizard, split across steps.
Local development
composer.json links the sibling ../schema checkout via a Composer path
repository, so local changes to meraki/schema are picked up immediately. This
needs "minimum-stability": "dev", because the linked checkout resolves as
dev-main (aliased to 1.13.x-dev by the core's extra.branch-alias).
The url is written as a glob (../{schema}) on purpose. A plain ../schema
makes composer update fail outright when the sibling checkout is not there,
which would break CI; a glob that matches nothing is simply skipped, so Composer
falls back to the VCS repository below it and resolves meraki/schema from
GitHub as before.
composer install
composer test