A hard fork of the TYPO3 system extension form — the read-only subtree split at
https://github.com/TYPO3-CMS/form, itself derived from typo3/sysext/form/ in
https://github.com/TYPO3/typo3.
It is installed via Composer as wapplersystems/form and transparently replaces
typo3/cms-form through Composer's replace mechanism. The Composer package name and
the TYPO3 extension key on disk are deliberately different (wapplersystems/form vs.
form) so the extension stays a drop-in for EXT:form — everything that references
\TYPO3\CMS\Form\…, the YAML mixins, the form editor JavaScript, the Fluid template
paths and the FAL config keeps working without changes.
Every addition is backwards compatible: existing forms keep working and features degrade gracefully (e.g. without JavaScript, or for forms that don't use them).
The TYPO3 core form sysext is intentionally minimal in places where editor workflows
benefit from more depth:
- More events — make finisher pipelines, variant evaluation and form rendering pluggable from outside via PSR-14 events instead of class extension.
- Backend editor parity — features that exist today only via YAML hand-editing (variants/conditions, complex validators, finisher options, translations) become editable in the form editor.
- Cross-field validators — validators that need more than a single field's value
(entropy/spam filtering across all submitted text, conditional
required, sums of numeric fields, etc.). - Visual variants/conditions editor — so integrators express conditional behavior without writing YAML.
- Consolidate
wapplersystems/form_extended— the patches and additions that live inform_extended(multi-upload, sender-address config in site settings, country/date/time fields, custom finishers) migrate into this fork step by step;form_extendedis then deprecated.
The fork's backend editor, shown here with the German interface language (labels
are shipped in Resources/Private/Language/de.Database.xlf):
-
Variant / condition editor — Variants (conditions) are editable directly in the form editor for every renderable (elements, pages, the form) and every finisher, instead of being YAML-only. Per variant: condition expression, visibility (
renderingOptions.enabled) and conditional required (NotEmpty). Round-trip-safe on save. Previously, opening + saving a form in the editor silently dropped hand-written variants. -
Visual condition builder — A Build… button next to any condition field opens a modal to click conditions together: rule rows (field + operator + value) with nested AND/OR groups, live expression preview, and parsing of existing expressions back into the tree (raw-text fallback for anything non-parseable).
-
Comfortable e-mail content editor — Each e-mail finisher gets an Edit email content button opening a large modal with: a template chooser, a rich-text HTML body and a separate plain-text body, a server-rendered preview (the real Fluid e-mail layout, filled with type-appropriate sample values), and a “Send test email” action. HTML and plain text are now independent finisher options.
-
Field-marker inserter — In the e-mail content editor (HTML and plain panes), an Insert field marker… dropdown drops
{fieldIdentifier}/{formValues}placeholders at the cursor, so editors don't type the placeholder syntax by hand.
No XLF authoring required — translations are stored inside the form definition and work for database-stored forms too.
-
Per-element translation — A Translate… button per element edits label, placeholder and option labels for every configured site language, with a completeness badge.
-
Form-wide translation overview — A Translate whole form… button on the form opens a matrix of every translatable string × every language in one place (including finisher options), with an overall completeness indicator.
-
Translatable validation messages — Custom validator error messages (
properties.validationErrorMessages) are translatable per language. Built-in validator messages remain localized via TYPO3's shipped XLF. -
Translatable finisher options — The text options of e-mail / confirmation finishers (
subject,message,plainMessage) are translatable per language, both from a per-finisher Translate… button and from the form-wide overview.
- Live conditions (same page) — Variants/conditions that reference fields on the same page react live in the browser (show/hide, toggle required) while the user types, without a server round-trip. The server stays authoritative on submit.
-
Variant-capable finishers — Any finisher can carry a
variantslist and be enabled/disabled (or otherwise overridden) by a condition on the submitted values (e.g. “send a copy to the sender only when the checkbox is ticked”). The dedicatedCopyToSenderEmailfinisher was removed in favour of this general mechanism. -
Additional PSR-14 events — Extra extension points around the whole form lifecycle (see the reference table below).
- Cross-field / form-level validators (e.g. an entropy-based spam filter).
- Extra form elements (
Time) and finishers (RedirectToUri,FeUser,AttachUploadsToObject) and view helpers. - Opt-in site-sender feature and opt-in validation-failure logging.
- Password-policy JSON endpoint.
- A self-contained build pipeline for the editor TypeScript sources under
Build/.
Replaces typo3/cms-form; install via Composer. The replace clause in this package's
composer.json makes Composer treat typo3/cms-form as already satisfied, so no second
copy is downloaded.
The dev14 Composer project loads this directory via the packages/*/* path repository.
To require it, replace the line
"typo3/cms-form": "14.3.*@dev",in the project's composer.json with
"wapplersystems/form": "dev-release/v14 as 14.3",The editor's TypeScript sources live under Build/. The pipeline compiles, rewrites
import specifiers, and deploys to Resources/Public/JavaScript/:
cd Build
npm install
npm run build # tsc → rewrite-imports → deployNote: the TYPO3 backend serves the editor as ES modules and caches them aggressively — after a rebuild, reload the editor with the browser cache bypassed (hard reload), or the stale module keeps running.
All fork-added events live in TYPO3\CMS\Form\Event\, alongside the events shipped by
upstream EXT:form, and are dispatched from patched upstream call-sites. Their class names
are distinct from the upstream ones, so the two sets never collide.
| Event | Fired from | Carries | Use-case |
|---|---|---|---|
BeforeFormPageProcessedEvent |
FormRuntime::processSubmittedFormValues() |
Page, FormRuntime, RequestInterface |
Preprocess submitted request args, snapshot for analytics, early-exit hooks |
BeforeFormIsValidatedEvent |
FormRuntime::mapAndValidatePage() (start) |
Page, FormRuntime, RequestInterface |
Setup before cross-field validators run (precompute shared values) |
AfterFormIsValidatedEvent |
FormRuntime::mapAndValidatePage() (end) |
Page, FormRuntime, RequestInterface, mutable Result |
Cross-field validators add errors via $event->result->forProperty(...)->addError(...). Also the hook for validation-failure logging. |
AfterVariantAppliedEvent |
FormRuntime::processVariants() |
VariableRenderableInterface, RenderableVariantInterface, FormRuntime |
React to dynamic form structure changes (cache invalidation, condition-match analytics) |
BeforeFinisherExecutedEvent |
AbstractFinisher::execute() |
FinisherInterface, FinisherContext |
Inject runtime values, log finisher invocations, call $context->cancel() to skip the rest of the chain |
AfterFinisherExecutedEvent |
AbstractFinisher::execute() |
FinisherInterface, FinisherContext, mixed (executeInternal result) |
Post-finisher logging, output transformation, follow-up actions. Does not fire on FinisherException. |
AfterYamlConfigurationLoadedEvent |
ConfigurationManager::getYamlConfiguration() |
mutable array $yamlConfiguration |
Inject runtime-computed values into the form-editor configuration (site languages, file mounts, dynamic option lists). Fires on every load, not cache-gated — listeners must be cheap. |
MailBeforeSendingEvent |
EmailFinisher::executeInternal() |
FluidEmail (mutable), FinisherContext, EmailFinisher |
Mutate the email immediately before transport — extra recipients, custom headers, conditional attachments, audit logging. Does not fire if EmailFinisher throws before reaching the transport step. |
AfterMailSentEvent |
EmailFinisher::executeInternal() |
FluidEmail, FinisherContext, EmailFinisher |
Fires only after a successful MailerInterface::send() — the reliable "delivered" hook for audit logging / post-delivery follow-ups (unlike MailBeforeSendingEvent, which can't tell success from a later transport failure). |
AfterFormStateInitializedEvent |
FormRuntime::triggerAfterFormStateInitialized() |
FormRuntime |
PSR-14 replacement for the legacy SC_OPTIONS['ext/form']['afterFormStateInitialized'] hook (still fired alongside). Canonical point to prefill form values from fe_user / GET-POST / session via the FormRuntime ArrayAccess API ($event->formRuntime['email'] = …). Fires every request, so guard first-display-only prefills. |
AfterFormSubmittedEvent |
FormRuntime::invokeFinishers() (after the chain) |
FormRuntime, array $formValues, string $renderedOutput, bool $wasCancelled |
Fires exactly once per submission after the whole finisher chain (Before/AfterFinisherExecuted fire per finisher). The hook for "submission complete" actions: conversion / analytics tracking, CRM sync, a single follow-up. wasCancelled reflects a finisher having called FinisherContext::cancel(). |
BeforeFinishersInvokedEvent |
FormRuntime::invokeFinishers() (before the chain) |
FormRuntime, mutable FinisherInterface[] $finishers, FinisherContext |
Fires once before the chain. Listeners may reorder / filter / inject finishers (FormRuntime iterates the modified $finishers), cancel the whole chain via $finisherContext->cancel(), or seed the shared FinisherVariableProvider. Counterpart to AfterFormSubmittedEvent. |
AfterDatabaseRecordPersistedEvent |
SaveToDatabaseFinisher::saveToDatabase() (also covers FeUserFinisher) |
string $table, int $uid, array $data, 'insert'|'update' $mode, FinisherInterface, FinisherContext |
Fires after a row was inserted/updated by a form. Hook for "record persisted" follow-ups (workflow on new fe_user, CRM push) with the inserted uid in hand. For update there is no single uid → $uid is 0; use $mode/$data. |
AfterFileUploadedEvent |
UploadedFileReferenceConverter::importUploadedResource() |
File $file, array $uploadInfo |
Fires right after an uploaded file is stored in FAL (before the FileReference is built). Use for virus scanning, EXIF/metadata stripping, content policy. A listener that throws aborts property mapping and thereby rejects the upload. |
AfterRenderableIsValidatedEvent |
FormRuntime::mapAndValidatePage() (per field) |
RenderableInterface, mixed $value, FormRuntime, RequestInterface, Result $validationResult |
Per-renderable companion to upstream BeforeRenderableIsValidatedEvent; fires after a field's processing rule ran. Inspect or add field-scoped errors via $event->validationResult->addError(...). Fires only for renderables that have a processing rule. For the page aggregate use AfterFormIsValidatedEvent. |
AfterFormRenderedEvent |
FormRuntime::render() |
FormRuntime, mutable string $renderedContent |
Fires after the renderer produced the form markup (page-render path only, not finisher output). Listeners may rewrite/wrap the markup — tracking pixel, JSON island for client-side logic, CSP nonces. FormRuntime returns the modified $renderedContent. |
The Before/After finisher events fire generically for every finisher inheriting
AbstractFinisher — Email, Redirect, Confirmation, SaveToDatabase, FlashMessage,
DeleteUploads, Closure and any custom finishers. No need to subclass per finisher type.
Variants whose condition references a field on the same page are applied live in the
browser (show/hide via renderingOptions.enabled, required via a NotEmpty validator) —
not just on the server at page/step transitions. Pieces:
InjectFrontendConditions(listener onAfterFormRenderedEvent) emits a JSON island<script type="application/json" data-wsform-conditions>inside the<form>carrying each element's{condition, enabled?, required?}rules, and loadsResources/Public/JavaScript/frontend/form-conditions.jsviaAssetCollector. Forms without such variants get neither.FormRuntime::render()re-enables (reEnableClientConditionElements()) elements that a client-evaluable variant disabled, so they are present in the DOM for the client to toggle. A condition is client-evaluable when it usestraverse(formValues, …)and no server-only context (stepType,finisherIdentifier, …). The server re-evaluates authoritatively on submit; without JS the fields stay visible and validation still holds.frontend/form-conditions.jsevaluates a subset of the ExpressionLanguage (traverse(formValues,"id"),== != < <= > >= in "not in" && || ()) against the live form values and toggles the field container ([data-wsform-element]),disabled(so hidden fields are not submitted) andrequired. Unparseable conditions are skipped.RenderableVariant::getCondition()/getOptions()expose the raw condition + override options for this.
Each element has a “Translate…” button opening a modal with one section per
non-default site language and inputs for the element's label, placeholder, options and
any custom validation messages. The form (root) element additionally offers a
“Translate whole form…” matrix covering every element and every finisher's text options
(subject, message, plainMessage).
Translations are stored in the form definition under
renderingOptions.translation.overrides.<languageCode> for elements and
options.translation.overrides.<languageCode> for finishers (round-trip via the
MultiValuePropertiesExtractors, so no XLF files are required — works for DB-stored forms
too). They are applied at render time before the XLF chain:
TranslationService::translateFormElementValue() (label / placeholder / options),
translateFormElementError() (validation messages, keyed c<code> to keep the path
segment non-numeric) and translateFinisherOption() (finisher options).
InjectTranslationEditorIntoFormElements injects the editor(s) + the available site
languages (SiteFinder, languageId !== 0); the inspector shows per-language completeness
badges.
The variants editor's condition field has a “Build…” button
(Build/Sources/TypeScript/form/backend/form-editor/condition-builder.ts) opening a modal
to click together rules (field / operator / value) with AND/OR groups and nesting. It
serializes the rule tree to an ExpressionLanguage condition and parses existing ones back
(raw-textarea fallback when unparseable). Pure editor JS.
| Element | Class | Notes |
|---|---|---|
Time |
TYPO3\CMS\Form\Domain\Model\FormElements\Time |
HTML5 <input type="time">. Backed by \DateTimeImmutable parsed with format H:i; the date portion is "today" — only the time portion is meaningful. Fills a real gap (core ships Date but no Time). |
| Identifier | Class | Purpose |
|---|---|---|
RedirectToUri |
TYPO3\CMS\Form\Domain\Finishers\RedirectToUriFinisher |
Redirect to any URI (external too). Core's Redirect only handles TYPO3 pages via t3-page IDs. Options: uri, statusCode (default 303). |
FeUser |
TYPO3\CMS\Form\Domain\Finishers\FeUserFinisher |
Insert/update fe_users rows from form values. Built on core's SaveToDatabase. Per-element hashPassword: true runs the value through PasswordHashFactory::getDefaultHashInstance('FE'). Requires pid option for the storage page. |
AttachUploadsToObject |
TYPO3\CMS\Form\Domain\Finishers\AttachUploadsToObjectFinisher |
Attaches uploaded files to an arbitrary DB record via new sys_file_reference rows. Pair with SaveToDatabase and reference the inserted UID via {SaveToDatabase.insertedUids.<index>}. Rebuild of the legacy form_extended finisher: direct ConnectionPool inserts, no fake backend user, no bypassAccessCheck hack, supports multiple files per element. |
Conditional finishers via variants (replaces the removed
CopyToSenderEmail): any finisher can carry avariantslist inside itsoptions, each entry being{ condition: <ExpressionLanguage>, ...overrides }. Before a finisher runs,FormRuntime::processFinisherVariants()merges every matching variant into the finisher options (formValues / stepType / finisherIdentifier are in scope). A "send me a copy" email is just a secondEmailToSenderwithrenderingOptions.enabled: falseand a variant{ condition: 'traverse(formValues, "sendCopy") == 1', renderingOptions: { enabled: true } }.
| Helper | Class | Use case |
|---|---|---|
<formvh:remoteAddress /> |
TYPO3\CMS\Form\ViewHelpers\RemoteAddressViewHelper |
Renders client IP via GeneralUtility::getIndpEnv('REMOTE_ADDR') (respects trusted-proxy config). Useful for audit-trailing email finishers / confirmation pages. |
<formvh:translate /> |
TYPO3\CMS\Form\ViewHelpers\TranslateViewHelper |
Form-aware translation wrapper that hits TYPO3\CMS\Form\Service\TranslationService (with its form-element overlay logic) instead of LocalizationUtility. Use inside form-rendering templates; outside use Fluid's f:translate. |
Validators that need access to more than a single field's value implement
TYPO3\CMS\Form\Validation\FormAwareValidatorInterface (or extend
AbstractFormAwareValidator). They are declared on the form root, not on an individual
element:
type: Form
identifier: contact
renderingOptions:
formLevelValidators:
-
identifier: EntropySpam
options:
minimumEntropy: 2.0
maximumEntropy: 5.5
textFieldIdentifiers: ['message', 'subject']
minimumLength: 30The validator identifier must be registered in the prototype's validatorsDefinition (the
standard prototype already registers EntropySpam). The internal listener
RunFormLevelValidators consumes AfterFormIsValidatedEvent and invokes each declared
validator after per-element validation has finished; errors merge into the form's aggregate
Result.
EntropySpamValidator ships as the first concrete cross-field validator and uses a
Shannon-entropy band to reject submissions that look either repetitive (aaaaaaa,
hahaha) or uniform-random (bot brute-force). Human-written text in most languages falls
between roughly 3.5 and 5.0 bits/character; the default band 1.8-5.8 is intentionally wide
to avoid false positives.
A frontend middleware at /_form/password-policy/ exposes TYPO3's configured FE password
policy ($GLOBALS['TYPO3_CONF_VARS']['FE']['passwordPolicy']) as a structured JSON
document. Client-side JavaScript can fetch it once and render a live "is your password
strong enough yet?" indicator next to a form's password field, in lockstep with the same
CorePasswordValidator that will validate the submission server-side.
Response shape:
{
"policy": "default",
"rules": [
{"id": "minimumLength", "label": "…", "value": 8},
{"id": "upperCaseCharacterRequired","label": "…"},
{"id": "lowerCaseCharacterRequired","label": "…"},
{"id": "digitCharacterRequired", "label": "…"},
{"id": "specialCharacterRequired", "label": "…"}
]
}Only rules the configured CorePasswordValidator actually enforces are emitted; a policy
that disables specialCharacterRequired simply won't return that rule, so the UI stays
consistent with the validator. Labels are translated against the active site default
language. The middleware is registered in Configuration/RequestMiddlewares.php after
cms-frontend/site (so the site context is available) and before
cms-frontend/page-resolver (so the JSON URL never enters page-not-found lookup).
Lets site administrators maintain a list of email sender addresses in the BE Site Configuration module; the form plugin's FlexForm then offers a dropdown to pick one per content element. The actual sender on outgoing emails is resolved at runtime from the selection.
Enable in Admin Tools → Settings → Extension Configuration → form:
form.featureSiteEmail = 1
After flushing caches and updating the schema, a new "Form senders" group appears in each
site's BE configuration with email and name fields per entry.
Architecture (classes follow the standard TYPO3\CMS\Form\… layout):
Configuration/SiteConfiguration/site_sender.php— TCA for the sub-site-entity withemailandnamecolumns.Configuration/SiteConfiguration/Overrides/site.php— adds an inlinesenderscollection to thesiteconfiguration. Only active whenfeatureSiteEmailis on.Form/FormDataProvider/SiteTcaInline+SiteDatabaseEditRow— Symfony-DI decorators of the core providers; they addsite_senderto the allowed inline tables. Registered inConfiguration/Services.yaml.EventListener/InjectSenderDropdownIntoFormPluginFlexForm— inserts asettings.senderSelect into the form-plugin FlexForm whose items are populated byHooks/SiteSenderItemsProcFunc.EventListener/HideStaticSenderFieldsInFormPluginFlexForm— hides the EmailToReceiver finisher'ssenderAddress/senderNamefields in the form-plugin FlexForm so editors aren't asked to fill in values that the site-sender will override anyway.EventListener/ApplySiteSenderToEmailFinisher— consumes the upstreamBeforeEmailFinisherInitializedEvent, reads the selected sender from the plugin's FlexForm, and rewrites the finisher'ssenderAddress/senderNameoptions.
When the feature flag is OFF, all listeners early-return and the decorators behave like the plain core data providers — zero runtime cost.
Enable per form to track which fields fail validation most often — useful for drop-off analysis without storing any user-submitted values:
type: Form
identifier: contact
renderingOptions:
recordValidationFailures: trueThe RecordValidationFailures listener (consumes AfterFormIsValidatedEvent) writes one
row to tx_form_validation_log per validation error with:
form_identifier,element_identifier,property_patherror_code,error_message(already translated, safe to store)page_uid,language_uid,page_indexsession_hash— SHA-256 of theFormSessionidentifier so multi-attempt patterns from one visitor can be aggregated without identifying themcrdate
What is NOT stored: submitted field values, raw inputs, IPs, user agents. The table is engineered to be GDPR-defensible by default.
Sample analytics query:
SELECT element_identifier, error_code, COUNT(*) AS hits
FROM tx_form_validation_log
WHERE form_identifier = 'contact' AND crdate > UNIX_TIMESTAMP() - 86400*30
GROUP BY element_identifier, error_code
ORDER BY hits DESC;Periodic cleanup. A native TYPO3 v14 scheduler task ships with the fork:
TYPO3\CMS\Form\Task\CleanupValidationLogTask. Configure it in
Administration → Scheduler → Create task and select Form: clean up validation log.
The tx_form_retention_days TCA field controls how old rows must be before deletion
(default 90 days, range 1–3650). Schedule it daily for production sites with active
validation logging — without it the table grows indefinitely. Manual run from CLI:
ddev typo3 scheduler:execute --task=<uid> after the task instance is created.
| Branch | Purpose |
|---|---|
release/v14 |
Active dev branch tracking TYPO3 14.x. Default branch. |
release/v15 |
Will be created when TYPO3 v15 ships. |
main |
Mirror of upstream main — never patched, sync-only. |
14.3, … |
Mirrors of upstream major branches — sync-only. |
Upstream is registered as the upstream remote
(https://github.com/TYPO3-CMS/form.git). The flow for picking up new upstream work is:
# Update upstream branches
git fetch upstream --prune --tags
# Mirror upstream/14.3 onto our read-only mirror branch
git push origin "refs/remotes/upstream/14.3:refs/heads/14.3" --force-with-lease
git push origin "refs/remotes/upstream/main:refs/heads/main" --force-with-lease
git push origin --tags
# Cherry-pick relevant commits onto release/v14
git checkout release/v14
git log --oneline release/v14..upstream/14.3 # what's new upstream
git cherry-pick <sha> # pick what we wantNever merge upstream/14.3 directly into release/v14. Use cherry-pick so the fork
history stays linear and grep-able; we want to see our changes without upstream noise
mixed in. When a new TYPO3 minor (e.g. 14.4) lands upstream, cherry-pick the relevant
commits up to that tag and adjust the branch-alias in composer.json.
- Code added on top of upstream lives in the standard
\TYPO3\CMS\Form\…layout, mirroring upstream's own directory structure (Event/,Validation/,Domain/Finishers/, …). Fork-added classes use distinct class names so they never collide with upstream files, and an eventual switch to an official package stays painless. The trade-off vs. a separate subnamespace: upstream cherry-picks can land in the same directories, so watch for conflicts when syncing. - Every public API surface we add gets a PSR-14 event so downstream extensions (including
wapplersystems/form_extendedduring the migration period) can consume it. - New editor UI is implemented through the form editor's existing extension points
(TypeScript under
Build/Sources/TypeScript/form/, partials registered viaformEditorPartials) rather than by patching upstream templates. - Commits that touch upstream files must explain why the upstream file needed to change — almost always preferable to add a hook/event upstream separately and contribute it back instead.





