Forming Forms is built to be extended. Forming Pro itself uses nothing but the public hooks on this page. Text domain: forming-form-builder. PHP namespace: Forming\. Function and hook prefix: forming_. Block namespace: forming/.
Architecture in one paragraph
A form is the parent block forming/form using InnerBlocks, restricted to an allowlist of field blocks. Every block is dynamic. Each has a PHP render callback, so the front-end markup is generated on the server. On submit, Forming\Submissions\Pipeline rebuilds the field schema from the form’s signed source with parse_blocks() and validates against that, never against the browser’s field list. Submissions are stored in the forming_entry post type, and a {prefix}forming_forms table indexes every form for the admin screens. There is no form CPT and no shortcode system. Reuse is core synced patterns.
- How a submission is processed
- [The
Submissionobject](#the-submission-object) - Field types
- PHP filters
- PHP actions
- Entry meta keys
- Editor (JS) filters
- Front-end (JS) events
- Admin screens
- Adding a form-level setting
How a submission is processed
POST /wp-json/forming/v1/submit hands every request to Forming\Submissions\Pipeline, which runs these steps in order. A step that fails stops the pipeline.
| # | Step | What happens | Hooks |
|---|---|---|---|
| 1 | Receive | Verify the REST nonce. | |
| 2 | Schema | Rebuild the form from its signed source with parse_blocks(). The browser’s field list is never trusted. | forming_form_settings, forming_field_definition |
| 3 | Protect | Honeypot and signed time-trap, then forming_spam_check: rate limit (priority 5), CAPTCHA (10), Akismet (20) and add-on checks. Spam gets a fake success response and is never stored or emailed. | forming_min_submit_seconds, forming_spam_check, forming_rate_limit, forming_captcha_providers, forming_akismet_data |
| 4 | Validate | Validate and sanitize each field by type, then let add-ons veto or change the result. | forming_rest_submit_request, forming_validate_field, forming_before_submission, forming_submission_data |
| 5 | Store | Save a forming_entry post, if the form stores submissions. | forming_entry_meta, forming_entry_created |
| 6 | Hand off | Tell add-ons about the submission. | forming_submission_saved |
| 7 | Notify | Send the notification email. | forming_notification_email |
| 8 | Respond | Return the success message or the redirect. | forming_success_response |
The Submission object
Most submission hooks receive a Forming\Submissions\Submission:
| Property / method | Type | Description |
|---|---|---|
$request | WP_REST_Request | The raw request (JSON or multipart). |
$form_id | string | The form block’s formId. |
$post_id | int | The post the form was viewed on. |
$source | string | Signed reference to the content the form block lives in (post:12, a template or template part, …). |
$settings | array | Normalized form settings (see forming_form_settings). |
$schema | array[] | Field definitions rebuilt from the blocks. |
$raw | array | Raw submitted values. Never trust these. |
$fields | array[] | Validated fields: name, label, type, value, display. |
$entry_id | int | Stored entry ID, or 0 when not stored (yet). |
data() | array | Validated values as name => value. |
field( $name ) | array|null | A single validated field. |
Field types
Every field block maps to a field type. The type has a validator, and optionally a definition callback that copies extra block attributes into the field definition the validator receives.
forming_register_field_types (action) — since 1.1.0
Register your own field type. The registry boots the first time it is used, during init, so add this hook from your main plugin file or on plugins_loaded. A registered block is added to the form’s allowlist automatically, so you don’t need forming_field_blocks as well.
add_action( 'forming_register_field_types', function ( Forming\Fields\Registry $registry ) {
$registry->register( 'rating', array(
// Block that renders the field. Default: "forming/field-{type}".
'block' => 'my-plugin/field-rating',
// Copy extra block attributes into the definition.
'definition_callback' => function ( $definition, $attributes ) {
$definition['max'] = isset( $attributes['max'] ) ? (int) $attributes['max'] : 5;
return $definition;
},
// Return array( 'value' => …, 'display' => … ) or a WP_Error.
'validate_callback' => function ( $raw, $field ) {
$stars = (int) $raw;
if ( $stars < 1 || $stars > $field['max'] ) {
return ! empty( $field['required'] ) || '' !== (string) $raw
? new WP_Error( 'invalid', __( 'Please choose a rating.', 'my-plugin' ) )
: array( 'value' => '', 'display' => '' );
}
return array( 'value' => $stars, 'display' => sprintf( '%d / %d', $stars, $field['max'] ) );
},
// Set to false for blocks that submit no value (buttons, page breaks).
'is_data' => true,
// Since 1.2.0: true when the field owns the field blocks nested in it
// (a repeater). Their definitions arrive in $field['fields'] instead of
// the form schema, and this field's validator checks them.
'inner_fields' => false,
) );
} );
The registry also provides unregister( $type ), get( $type ), get_by_block( $block_name ), all() and blocks(). Forming\Fields\Validators holds the built-in validators, along with the Validators::ok(), Validators::required_error( $field ) and Validators::invalid_error( $field, $default_message ) helpers. Pass the field definition so the author’s Message when empty and Message when the value is not valid settings (requiredMessage, invalidMessage) are used.
Your block’s render_callback must output an input named fields[{name}], or fields[{name}][] for multiple values. Derive the name with Forming\Submissions\Schema::field_name( $attributes ) so that rendering and validation always agree.
“Other (please specify)” choices (radio and checkbox groups, since 1.2.0): the choice submits Validators::OTHER (__other__) in fields[{name}], and the typed answer arrives separately in forming_other[{name}]. Fields with allowOther receive it as otherText in their definition, and the stored value is the typed text. Validators::multiple_choice() validates multi-select and checkbox groups, including that answer.
forming_field_blocks (filter) — since 1.0.0
The list of block names allowed inside a form. It already includes the blocks of registered field types. A block that is allowed here but not registered is validated as single-line text.
add_filter( 'forming_field_blocks', function ( $blocks ) {
$blocks[] = 'my-plugin/field-notes';
return $blocks;
} );
PHP filters
forming_form_settings — since 1.1.0
Filters a form’s normalized settings. Use it to read attributes your add-on added to the forming/form block.
add_filter( 'forming_form_settings', function ( $settings, $attrs ) {
$settings['entryLimit'] = isset( $attrs['entryLimit'] ) ? absint( $attrs['entryLimit'] ) : 0;
return $settings;
}, 10, 2 );
| Param | Type | |
|---|---|---|
$settings | array | formId, recipients, subject, fromName, fromEmail, successAction, successMessage, redirectUrl, honeypot, timeTrap, captcha, storeSubmissions. |
$attrs | array | Raw attributes of the form block. |
forming_field_definition — since 1.1.0
Filters a field definition while it is rebuilt from its block, after the type’s definition_callback has run.
| Param | Type | |
|---|---|---|
$definition | array | type, block, name, label, required, options, mode, requiredMessage, invalidMessage, plus type-specific keys: minLength, maxLength and pattern (text, textarea, phone), minDate and maxDate (date, today or YYYY-MM-DD), min, max and step (number). |
$attributes | array | Block attributes, with block.json defaults applied. |
$block_name | string | Block name. |
forming_spam_check — since 1.1.0
Return true for a clean submission or a WP_Error to reject it. A rejected submission gets a fake success response by default. To show your message to the visitor instead, add 'silent' => false to the error data, for example for a failed CAPTCHA:
add_filter( 'forming_spam_check', function ( $result, $submission ) {
if ( is_wp_error( $result ) ) {
return $result; // An earlier check already flagged it.
}
$token = (string) $submission->request->get_param( 'my_captcha_token' );
if ( ! my_captcha_verify( $token ) ) {
return new WP_Error( 'my_captcha', __( 'Please complete the CAPTCHA.', 'my-plugin' ), array( 'silent' => false ) );
}
return $result;
}, 10, 2 );
The built-in checks set these error codes: forming_spam_honeypot, forming_spam_time_signature and forming_spam_too_fast. The checks hooked into this filter add forming_rate_limited, forming_captcha_missing, forming_captcha_failed, forming_captcha_unreachable, forming_captcha_low_score (reCAPTCHA v3, silent) and forming_spam_akismet (silent).
forming_rate_limit — since 1.2.0
Filters the per-IP submission limit. Return limit (submissions, 0 for no limit) and window (seconds). Defaults to the Submissions allowed per visitor per hour setting over one hour.
add_filter( 'forming_rate_limit', function ( $limit, $submission ) {
if ( 'newsletter' === $submission->form_id ) {
$limit['limit'] = 3;
}
return $limit;
}, 10, 2 );
forming_client_ip — since 1.2.0
Filters the visitor IP used by the rate limit, CAPTCHA, Akismet and the stored entry IP. Behind a proxy or CDN, return the address from a header your own proxy sets, such as CF-Connecting-IP.
forming_captcha_providers — since 1.2.0
The CAPTCHA services offered under Settings → Spam protection, keyed by id. Add an instance of a Forming\Spam\Captcha\Provider subclass. It must implement id(), label(), script_url() and verify_url(). Its keys are stored as {id}_site_key and {id}_secret_key, and its settings fields are added for you. Override mode() (checkbox, invisible or score), extra_fields() or check_result() as needed. The front-end script supports the reCAPTCHA, hCaptcha and Turnstile JavaScript APIs; a provider with a different API needs its own script that puts the token into the forming_captcha_token field.
forming_akismet_data — since 1.2.0
Filters the parameters sent to Akismet’s comment-check. By default comment_author is the first text field whose name contains “name”, comment_author_email the first email field, comment_author_url the first URL field and comment_content the textarea values. Runs only when Akismet is active and connected and Check submissions with Akismet is on.
forming_dynamic_value — since 1.2.0
Filters a Hidden field’s server-side dynamic value at submission: post_title, post_id, post_url (the page the form was viewed on, if public), user_email, user_name, user_id (the logged-in visitor) and date. These ignore whatever the browser sent. An empty result falls back to the field’s default value. query (a URL parameter) and referrer are filled in by the browser and can be changed by the visitor, so don’t trust them.
| Param | Type | |
|---|---|---|
$value | string | Resolved value. |
$type | string | Dynamic value type. |
$post_id | int | Post the form was viewed on. |
forming_min_submit_seconds — since 1.0.0
The minimum number of seconds between rendering a form and submitting it (default 2).
forming_rest_submit_request — since 1.1.0
Filters the raw fields[…] values before validation. Use it to merge in values from elsewhere in the request, such as uploaded files ($request->get_file_params()). Whatever you add still goes through the field’s validator.
| Param | Type | |
|---|---|---|
$raw | array | Raw values keyed by field name. |
$request | WP_REST_Request | The request. |
$submission | Submission |
forming_validate_field — since 1.1.0
Filters a single field’s validation result. Return a WP_Error to reject the value (its message is shown under the field), or an array with value and display to accept it.
| Param | Type | |
|---|---|---|
$result | array|WP_Error | Result from the type’s validator. |
$field | array | Field definition. |
$raw | mixed | Raw submitted value. |
forming_before_submission — since 1.1.0
Runs after every field is valid and before anything is stored or sent. Return a WP_Error to stop the submission. Use it for entry limits, schedules, payments and similar checks. The error message is shown above the form. To flag fields, add errors (name => message) to the error data. The response status defaults to 400; set status in the error data to change it.
add_filter( 'forming_before_submission', function ( $result, $submission ) {
$limit = isset( $submission->settings['entryLimit'] ) ? $submission->settings['entryLimit'] : 0;
if ( $limit && my_count_entries( $submission->form_id ) >= $limit ) {
return new WP_Error( 'my_limit', __( 'This form is no longer accepting entries.', 'my-plugin' ) );
}
return $result;
}, 10, 2 );
forming_submission_data — since 1.1.0
Filters the validated fields (name, label, type, value, display) before they are stored and emailed.
forming_entry_display_value — since 1.2.0
Filters how a stored entry value is shown in the Entries screen, CSV export, dashboard widget and personal data export, with ( string $display, array $field ). $field has name, label, type, value and, for choice fields, options. Use it for custom field types, for example to show a toggle’s 1 as “Yes”.
forming_entry_meta — since 1.1.0
Filters the meta saved with a new entry, as key => value. Prefix your own keys with your plugin’s prefix.
| Param | Type | |
|---|---|---|
$meta | array | See Entry meta keys. |
$entry_id | int | |
$submission | Submission|null |
forming_notification_email — since 1.0.0
Filters the notification email before wp_mail(): array( 'recipients', 'subject', 'body', 'headers' ), plus $settings, $fields and $context.
forming_success_response — since 1.1.0
Filters the JSON sent to the browser after a successful submission. The payload has success, action (message or redirect), and message (HTML) or redirect (URL). It is not applied to the fake response sent for spam.
add_filter( 'forming_success_response', function ( $payload, $submission ) {
if ( $submission->entry_id ) {
$payload['message'] .= sprintf( ' Your reference is #%d.', $submission->entry_id );
}
return $payload;
}, 10, 2 );
PHP actions
forming_init — since 1.2.0
Fires once Forming has loaded, on init at priority 0, with the Forming\Plugin instance. Boot add-ons here: every Forming class is loaded and FORMING_VERSION is defined.
forming_admin_enqueue_scripts — since 1.2.0
Fires on Forming admin screens just before the admin app is enqueued, with ( string $page, string $hook_suffix ). Enqueue add-on scripts here, in the footer, so they print before the app and their forming.admin.* filters are in place when it renders.
forming_entry_created — since 1.1.0
Fires after an entry has been stored, with ( int $entry_id, Submission|null $submission ).
forming_submission_saved — since 1.0.0
Fires for every valid submission, whether or not it was stored. Spam never reaches it.
| Param | Type | |
|---|---|---|
$entry_id | int | Entry ID, or 0 when the form doesn’t store submissions. |
$data | array | name => value. |
$form_id | string | |
$submission | Submission | Since 1.1.0. Hook with 10, 4 to receive it. |
Entry meta keys
Entries are forming_entry posts. These meta keys are stable:
| Key | Value |
|---|---|
_forming_form_id | Form identifier. |
_forming_source_id | ID of the post the form was submitted from. |
_forming_source | Signed reference to the content the form lives in. Since 1.1.0. |
_forming_user_id | ID of the logged-in submitter; absent for visitors. Since 1.1.0. |
_forming_fields | Snapshot of the fields at submission time: a list of name, label, type and, for choice fields, options (label, value). Since 1.1.0. |
_forming_read | Set once the entry has been opened or marked as read; absent while unread. Since 1.1.0. |
_forming_starred | Set on starred entries. Since 1.1.0. |
_forming_ip, _forming_user_agent | Submitter IP address and browser, only when enabled under Settings → Data & privacy. Since 1.1.0. |
forming_field_{name} | One value per field (an array for multiple choices). |
Trashed entries use the trash post status and are restored as publish.
Editor (JS) filters
These use @wordpress/hooks (the wp-hooks script).
forming.form.inspectorPanels — since 1.1.0
Adds panels to the Form block’s sidebar, below the built-in ones.
import { addFilter } from '@wordpress/hooks';
import { PanelBody, TextControl } from '@wordpress/components';
addFilter( 'forming.form.inspectorPanels', 'my-plugin/limits', ( panels, { attributes, setAttributes } ) => (
<>
{ panels }
<PanelBody title="Entry limit" initialOpen={ false }>
<TextControl
type="number"
label="Maximum entries"
value={ attributes.entryLimit || '' }
onChange={ ( value ) => setAttributes( { entryLimit: Number( value ) } ) }
/>
</PanelBody>
</>
) );
Props: { attributes, setAttributes, clientId }. Always render the incoming panels value so other add-ons’ panels still show.
forming.field.inspectorControls — since 1.1.0
Adds controls at the end of the shared “Field Settings” panel of every field block that uses it. Props: { name, clientId, attributes, setAttributes }, where name is the block name. Check name to target specific fields, and always render the incoming value.
Front-end (JS) events
Events are dispatched on the <form> element and bubble, so you can listen on document.
| Event | Cancelable | detail | Use |
|---|---|---|---|
forming:before-submit | yes | { formData } | Add or change values in formData (for example a CAPTCHA token). Call preventDefault() to stop the send. |
forming:success | yes | { payload } | React to success. Call preventDefault() to skip the built-in message/redirect handling. |
forming:error | no | { payload, status } | React to errors. status is 0 for a network error. |
document.addEventListener( 'forming:before-submit', ( event ) => {
event.detail.formData.set( 'my_captcha_token', window.myCaptcha.getToken() );
} );
Client-side validation (since 1.2.0) runs before forming:before-submit, using the rendered HTML attributes (required, minlength, maxlength, pattern, min, max, step, input type) and the same messages as the server. The form’s data-forming-messages attribute carries the translated messages, and data-required-message / data-invalid-message on a control carry its custom ones. Invalid controls get aria-invalid="true", and the error is linked through aria-describedby. Focus moves to the first invalid control, and errors update as the visitor types. The server still validates every submission. Add data-forming-server-only to a field wrapper to skip client-side checks for it and every field inside it (for example a repeater whose inner fields share its name); its server error then shows on that wrapper.
Admin screens
forming_capability (filter) — since 1.1.0
The capability needed for a Forming admin screen and its REST endpoints. The default is manage_options. $context is one of dashboard, forms, entries, settings or tools.
add_filter( 'forming_capability', function ( $capability, $context ) {
return 'forms' === $context ? 'edit_pages' : $capability;
}, 10, 2 );
forming_admin_menu_items (filter) — since 1.1.0
The Forming submenu pages, as a list of slug, title, menu_title and capability. A page you add gets a submenu item and the admin app shell (header and tabs). Render its content with the JS filter below.
forming.admin.pages (JS filter) — since 1.1.0
Maps page slugs to React components. The current slug is in window.formingAdmin.page.
addFilter( 'forming.admin.pages', 'my-plugin/license', ( pages ) => ( {
...pages,
license: LicensePage,
} ) );
Entries endpoints
All of these require the entries capability (see forming_capability).
| Route | Description |
|---|---|
GET admin/entries | List. Accepts search (field values), form_id, status (all, unread, read, starred, trash), order, page and per_page. Returns X-WP-Total, X-WP-TotalPages and X-Forming-Unread headers. |
GET admin/entries/{id} | One entry with its fields, user, and the newer/older entry of the same form (prev_id, next_id). |
POST admin/entries/{id} | Set read and/or starred. |
POST admin/entries/batch | ids plus action: read, unread, star, unstar, trash, restore or delete (trashed entries only). |
GET admin/entries/forms | Forms that have entries, for the form filter. |
CSV export is admin-post.php?action=forming_export_entries&form_id=… (optionally from and to as Y-m-d), with a forming_export_entries nonce.
Settings
Global settings live in the forming_settings option and are read with Forming\Settings\Settings::get( $id, $fallback ). The Settings screen is generated from a schema, so a new tab or field needs no JS.
forming_settings_tabs (filter) — since 1.1.0
The tabs, each with id and title. Add 'save' => false (since 1.2.0) for a tab that has no settings to save, such as one whose UI comes entirely from forming.admin.settingsTab; its Save button is hidden.
forming_settings_fields (filter) — since 1.1.0
The fields. Each has id, tab, type (text, email, password, textarea, toggle, number or select), label and default, plus optional help, options (for select), min, max and show_if. show_if maps setting ids to the values that show the field, for example array( 'captcha_provider' => array( 'turnstile' ) ). Values are sanitized by type when saved.
add_filter( 'forming_settings_tabs', function ( $tabs ) {
$tabs[] = array( 'id' => 'license', 'title' => __( 'License', 'my-plugin' ) );
return $tabs;
} );
add_filter( 'forming_settings_fields', function ( $fields ) {
$fields[] = array(
'id' => 'my_license_key',
'tab' => 'license',
'type' => 'text',
'label' => __( 'License key', 'my-plugin' ),
'default' => '',
);
return $fields;
} );
forming.admin.settingsTab (JS filter) — since 1.1.0
Renders extra UI at the end of a settings tab. Arguments: ( element, tabId, values, update ), where update( id, value ) changes a value.
Dashboard
forming_dashboard_data (filter) — since 1.1.0
Filters the Dashboard data (cards, series, latest, top_forms, checklist, …). Add your own keys and render them with forming.admin.dashboardWidgets.
forming_setup_checklist (filter) — since 1.1.0
The setup checklist items, each with id, label, done, help, and an optional action (label and url).
forming.admin.dashboardWidgets (JS filter) — since 1.1.0
Renders extra widgets below the Dashboard lists. Arguments: ( element, data ).
Log
Forming\Support\Log::add( $type, $message, $context = array(), $level = 'error' ) writes to the rolling log shown under Forming → Tools → Logs (the last 100 entries are kept). Forming logs failed notification and test emails there.
Privacy
Forming registers a personal data exporter and eraser (Tools → Export/Erase Personal Data) that match entries by any field equal to the email address, and suggests text for the privacy policy. Optional IP/browser storage adds the _forming_ip and _forming_user_agent entry meta. Entry retention runs daily on the forming_delete_old_entries cron hook.
More admin endpoints
| Route | Description |
|---|---|
GET admin/dashboard | Dashboard data. days (7, 30, 90) and form_id. |
POST admin/dashboard/checklist | dismissed: hide or show the setup checklist for the current user. |
GET / POST admin/settings | Schema and values / save values. Returns field errors on 400. |
POST admin/settings/test-email | Send a test email to to. |
GET admin/tools/status | System status groups. |
POST admin/tools/reindex | Rebuild the forms index now. |
GET admin/tools/logs, POST admin/tools/logs/clear | Read / clear the log. |
GET admin/tools/settings/export, POST admin/tools/settings/import | Export settings as JSON / import a data object from an export. |
GET /wp-json/forming/v1/admin/forms
Lists indexed forms. It accepts search, status, orderby (updated_at, entry_count, last_entry_at, title), order, page and per_page, and returns X-WP-Total and X-WP-TotalPages headers.
Adding a form-level setting
A complete add-on setting touches four places:
- Attribute (PHP): add it to
forming/formwith the coreregister_block_type_argsfilter. - Attribute (JS): add it with the
blocks.registerBlockTypefilter so the editor saves it. - UI: render a control through
forming.form.inspectorPanels. - Server: read it through
forming_form_settings, then act on it informing_before_submission,forming_submission_savedand so on.
add_filter( 'register_block_type_args', function ( $args, $name ) {
if ( 'forming/form' === $name ) {
$args['attributes']['entryLimit'] = array( 'type' => 'number', 'default' => 0 );
}
return $args;
}, 10, 2 );
addFilter( 'blocks.registerBlockType', 'my-plugin/limit-attr', ( settings, name ) =>
'forming/form' !== name
? settings
: { ...settings, attributes: { ...settings.attributes, entryLimit: { type: 'number', default: 0 } } }
);
The submit endpoint
POST /wp-json/forming/v1/submit accepts JSON or multipart form data.
| Parameter | Notes |
|---|---|
forming_form_id | The Form block’s formId (required). |
forming_post_id | ID of the post the form was viewed on (required). |
forming_source, forming_source_sig | Signed reference to the content the form lives in. |
forming_nonce | A wp_rest nonce, also accepted in the X-WP-Nonce header. The bundled script fetches a fresh one when the page may be cached. |
forming_time, forming_time_sig | Signed render timestamp for the time-trap. |
forming_hp | The honeypot. Must be empty. |
forming_captcha_token | CAPTCHA response token, when the form uses CAPTCHA. |
fields[...] | Submitted values keyed by field name (fields[name][] for multiple values). |
forming_other[...] | Typed “Other (please specify)” answers. |
The route is public and protected by the nonce, signatures and spam checks, so logged-out visitors can submit. Responses:
- 200:
{ success: true, action: "message", message: "…" }or{ success: true, action: "redirect", redirect: "…" } - 403
forming_invalid_nonce: expired or missing nonce - 404
forming_form_not_found: no form with that ID at that source - 422
forming_validation_failed: withdata.errorsas a field name → message map - 429 or 400: rate limit, CAPTCHA or
forming_before_submissionerrors with a message to show
A submission caught by a silent spam check returns the same 200 payload as a valid one, without storing or emailing anything.
WP-CLI
wp forming forms list # List indexed forms (supports --format)
wp forming forms reindex # Rebuild the forms index
Useful classes
| Class | Role |
|---|---|
Forming\Fields\Registry | Field types: register(), get(), get_by_block(), all() |
Forming\Fields\Validators | Built-in validators and the ok(), required_error() and invalid_error() helpers |
Forming\Blocks\Renderer | Shared field markup, and allowed_html() for wp_kses() |
Forming\Submissions\Schema | Schema rebuilding, validation and field_name() |
Forming\Submissions\Pipeline | The submission steps |
Forming\Submissions\Submission | The object passed to submission hooks |
Forming\Submissions\Entries | The forming_entry post type and entry creation |
Forming\Settings\Settings | Global settings: Settings::get( $id, $fallback ) |
Forming\Support\Log | The log shown under Tools → Logs |
Building from source
npm install && composer install
npm run start # watch
npm run build # production build into assets/ and a fresh .pot
Block sources live in src/blocks/, the admin app in src/admin/, and compiled output in assets/. PHP follows the WordPress Coding Standards, and the ruleset is in phpcs.xml.