Download the PHP package wapplersystems/form without Composer

On this page you can find all versions of the php package wapplersystems/form. It is possible to download/install these versions without Composer. Possible dependencies are resolved automatically.

FAQ

After the download, you have to make one include require_once('vendor/autoload.php');. After that you have to import the classes with use statements.

Example:
If you use only one package a project is not needed. But if you use more then one package, without a project it is not possible to import the classes with use statements.

In general, it is recommended to use always a project to download your libraries. In an application normally there is more than one library needed.
Some PHP packages are not free to download and because of that hosted in private repositories. In this case some credentials are needed to access such packages. Please use the auth.json textarea to insert credentials, if a package is coming from a private repository. You can look here for more information.

  • Some hosting areas are not accessible by a terminal or SSH. Then it is not possible to use Composer.
  • To use Composer is sometimes complicated. Especially for beginners.
  • Composer needs much resources. Sometimes they are not available on a simple webspace.
  • If you are using private repositories you don't need to share your credentials. You can set up everything on our site and then you provide a simple download link to your team member.
  • Simplify your Composer build process. Use our own command line tool to download the vendor folder as binary. This makes your build process faster and you don't need to expose your credentials for private repositories.
Please rate this library. Is it a good library?

Informations about the package form

wapplersystems/form

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).

Why fork?

The TYPO3 core form sysext is intentionally minimal in places where editor workflows benefit from more depth:

  1. More events — make finisher pipelines, variant evaluation and form rendering pluggable from outside via PSR-14 events instead of class extension.
  2. 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.
  3. 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.).
  4. Visual variants/conditions editor — so integrators express conditional behavior without writing YAML.
  5. Consolidate wapplersystems/form_extended — the patches and additions that lived in form_extended (multi-upload, sender-address config in site settings, country/date/time fields, custom finishers) have been absorbed into this fork. The migration is complete: composer.json now declares "wapplersystems/form_extended": "self.version" in its replace block, so packages still requiring form_extended stay satisfied and a parallel installation is impossibleform_extended's FormEditorController XCLASS would otherwise displace the fork's own and regress the RTE and e-mail content editors.

Spam protection without CAPTCHAs

Four layers, each enforced on the server, none of which asks the visitor to prove anything. No third-party service, no image puzzles, no request leaving the site, nothing to accept in a cookie banner.

# Layer Catches Visitor cost Origin
1 Honeypot field — hidden by CSS, with a per-session random name Form fillers that populate every field they can parse none TYPO3 core
2 Entropy spam filter — Shannon entropy band plus a per-token gibberish check across the free-text fields Machine-generated text (aaaaaaa, vOYhcWlrcTafTMSelBkM) none fork
3 JavaScript challenge — signed token, obfuscated in the markup, reversed by the browser Clients without a JavaScript engine requires JS fork
4 Minimum fill-in time — measured in the browser, corroborated against the render age Submissions arriving faster than a human could type none fork
+ Validation logging — every failure recorded, no submitted values none fork

Layers 2–4 combine freely and are configured per form, or prototype-wide to cover every form of a site at once. All four work on fully cached pages, which is where most home-grown attempts fall over.

Measured on a live site

One production contact form, 27 days, with layers 1 and 2 active and logging on:

Logged validation failures 15,332
Of those, bot signatures (entropy filter + honeypot) 15,139 — 98.7 %
Sessions with a bot signature 125
Sessions from genuine visitors 10
Attempts per bot session ~125, paced at 7.0 s, in ~15-minute bursts
Submitted values stored none

The last row is about the validation log, which stores no user input at all. The separate mail log can optionally store a recipient address for forms that opt in — see that section for what it does and does not keep.

The traffic was almost entirely automated — 125 attacking sessions against 10 real ones — and none of it reached the mailbox. What the logging then makes visible is the other 1.3 %: of the ten genuine visitors who tripped a validator, nine had left the message empty and seven had submitted the form without filling in anything at all. That is a usability finding rather than a spam finding, and it is the kind of thing you can only act on if you measure it.

What it does not claim

Stating these plainly is the point: a spam defence whose limits you know beats one you merely believe in.

Technical detail: Cross-field validators, JavaScript spam shield, Validation-failure logging.


New features

Screenshots

The fork's backend editor, shown here with the German interface language (labels are shipped in Resources/Private/Language/de.Database.xlf):

Variants & conditions plus per-element and whole-form translation, right in the inspector. The whole-form translation matrix (every translatable string × every site language).
Rich-text HTML body of an e-mail finisher, with field-marker insertion. Separate plain-text body (left empty it is derived from the HTML automatically).
Server-rendered preview (real Fluid e-mail layout, filled with sample values) and a test-send action. The visual condition builder with a live expression preview.

Backend editor

Localization (in-editor, per site language)

No XLF authoring required — translations are stored inside the form definition and work for database-stored forms too.

Frontend

Runtime

Other additions carried by the fork


Installation

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 same clause covers wapplersystems/form_extended: a project that still requires it resolves without changes, and it can no longer be installed alongside the fork — remove the requirement at your next opportunity.

Local development inside the dev14 monorepo

The dev14 Composer project loads this directory via the packages/*/* path repository. To require it, replace the line

in the project's composer.json with

Building the editor JavaScript

The editor's TypeScript sources live under Build/. The pipeline compiles, rewrites import specifiers, and deploys to Resources/Public/JavaScript/:

Note: 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.


Reference

Fork-added PSR-14 events

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.
FinisherFailedEvent AbstractFinisher::execute() catch block FinisherInterface, FinisherContext, FinisherException Record, alert on or count finisher failures. Fires only for FinisherException — an RFC-invalid sender address, a Fluid error in a mail template (rendered lazily inside send()) or a hard abort produce no terminal event at all, so consumers must treat "neither After nor Failed" as its own outcome. Listeners must not throw.
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.

Frontend live-conditions (same-page variants)

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:

In-editor localization (per-site-language translations)

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.

Visual condition builder (form editor)

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.

Multi-file upload (frontend)

A native <input type="file" multiple> replaces its whole FileList on every pick and offers no way to drop a single file again: choosing files twice silently loses the first selection, and a mis-picked file can only be corrected by re-picking everything.

For a file upload with properties.multiple, FileUpload.fluid.html therefore emits a markup contract for Resources/Public/JavaScript/frontend/file-upload.js:

The script keeps a DataTransfer as the source of truth, appends newly picked files to it, and renders one removable <li data-form-multi-upload-pending> per pending file into that list — reusing the server-rendered list of already persisted files when one is present, creating it directly after the input otherwise. Files are identified by name, size and lastModified, so two picked files sharing a name remove independently and re-picking an identical file does not add a duplicate.

Script and Resources/Public/Css/file-upload.css are registered via f:asset.* and only for a multi-file field — a single upload keeps its plain markup and loads neither.

Promoted from wapplersystems/form_extended, with three defects fixed there: it matched every input[multiple] on the page (including selects), addressed its container by walking nextElementSibling, and removed DataTransfer entries by file name alone.

The server-side removal of persisted files (properties.allowRemovalUploadDeleteCheckboxViewHelper__deleteFile) is untouched and remains the mechanism for anything already written to FAL.

E-mail templates

The template chooser of the e-mail content modal reads renderEmailContentEditor → availableTemplates — a templateName => label map on editor 250 of EmailToReceiver / EmailToSender in Configuration/Form/Base/FormElements/Form.yaml:

Declaring the default in YAML (rather than falling back to {Default: 'Default'} inside the JavaScript) means an extension shipping its own e-mail templates merges additively and Default stays in the dropdown.

Extensions that still target the standalone “Template” dropdown at editor index 1800, which form_extended used to inject, must be adjusted: that editor no longer exists, and an override targeting 1800 creates an editor node without a propertyPath, on which SelectOptionsExtractor throws (#1329289436) and takes down saving in the form editor entirely.

Form elements added on top of upstream

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).

Finishers added on top of upstream

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 a variants list inside its options, 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 second EmailToSender with renderingOptions.enabled: false and a variant { condition: 'traverse(formValues, "sendCopy") == 1', renderingOptions: { enabled: true } }.

View helpers added on top of upstream

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.

Cross-field (form-level) validators

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 — with a validators: list, exactly as on any other renderable:

The older spelling renderingOptions.formLevelValidators keeps working; entries from both sources run. In the form editor the same list is reachable on the form root as Form-wide validators, next to Finishers.

Upstream files touched: making the form root carry validators and finishers at the same time required a fix in AddHmacDataConverter and FormDefinitionValidationService::validateFormDefinitionProperties(). Both picked one property collection per element and named it after the element type — finishers for the form root, validators for everything else — an assumption that no longer holds. With the old code a form that has finishers left its validators unvalidated on save, and a form without finishers had its validator hashes written out under a finishers key, i.e. a phantom finisher the editor would load and persist. Both now key by the actual array key; behaviour for elements with a single collection is unchanged, and FormDefinitionConversionServiceTest guards it.

The validator identifier must be registered in the prototype's validatorsDefinition (the standard prototype registers EntropySpam, MinimumFillTime and Challenge). 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. Because a form-root error only renders where the template has a summary block, Frontend/Templates/Form.fluid.html ships one.

errorMessage accepts an LLL: reference, resolved against the active site language — form-level validator options are not covered by the form's XLF chain. An empty errorMessage falls back to the validator's own shipped (translated) default rather than rejecting silently.

EntropySpamValidator 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.

JavaScript spam shield (challenge/response + minimum fill time)

Two independent mechanisms, modelled on EXT:form_crshield. They share one JSON island, one hidden-field pair and one 4 kB frontend module (Resources/Public/JavaScript/frontend/challenge.js), all emitted by InjectFormChallenge on AfterFormRenderedEvent. A form that uses neither is rendered byte-for-byte as before.

Challenge/response

FormChallengeService issues a tokenbase64url(json{form, issuedAt, nonce}) plus an HMAC-SHA256 signature over it — and the markup carries an obfuscated form of it, the challenge. The frontend module reverses the obfuscation after a configurable delay and writes the token into a hidden field; ChallengeValidator verifies the signature, the form binding and (optionally) the age. A client that never ran JavaScript submits nothing usable, and one that copies the challenge back verbatim submits a string whose signature does not verify.

Putting the validator on a form is the only switch, and every setting lives on it:

delay and obfuscationMethod shape the markup and are read by InjectFormChallenge off the validator; maxAge and errorMessage shape the verdict and are read by the validator itself. They sit together because they are one feature — an earlier version split them between the validator and a renderingOptions.challenge block, which meant configuring one thing in two places.

delay and obfuscationMethod are editable in the form editor, in the Form-wide validators inspector alongside the validator itself. maxAge is deliberately not editor-facing, because it interacts with the page cache and a wrong value silently rejects legitimate submissions.

Trade-off worth knowing: a validators list on the form root cannot be defaulted prototype-wide, so there is no longer a one-line way to arm every form of a site at once. If you want that, add the validator from a listener on AfterFormIsBuiltEvent rather than reintroducing a parallel settings block.

The obfuscation is not cryptography and is not meant to be — the reversing algorithm ships to every visitor. Its only job is that a bot copying values out of the markup into the form submits something that fails the signature check. The property the shield actually provides is "a JavaScript engine ran and transformed the challenge".

Why the scheme is stateless. The initial render of a form goes through the cacheable render action (only perform is non-cacheable), so the challenge is written into the page cache and served to many visitors over the cache lifetime. Nothing may therefore live in the session, and maxAge defaults to 0: a max age below the page cache lifetime would reject legitimate submissions from a cached page. Raise it only for a form on an uncached page. This is the same trade-off form_crshield manages with its minimumPageExpirationTime/additionalPageExpirationTime settings.

Minimum fill-in time

MinimumFillTimeValidator is an ordinary form-level validator — putting it on the form is also what makes the rendering side emit the measurement field, there is no second switch:

It runs two checks:

  1. The elapsed time the browser measured (performance.now(), written into a hidden field on interaction and on submit). Client-asserted — a bot that runs JavaScript can claim any duration. It is meant to cost more than the average spam run will pay, and under full page caching it is the only per-visitor measurement available at all.
  2. The age of the challenge token, when the challenge is enabled too. That timestamp comes from the server, so it cannot be forged — but it says when the markup was produced, which on a cached page is not when the visitor started typing. It can therefore only ever prove a submission is too fast, never that it is fast enough. It costs nothing, cannot false-positive (a form cannot have been on screen longer than it has existed), and it catches a bot that fakes the elapsed time but submits immediately.

allowMissingTimingData is off by default, so a submission reporting no time at all — JavaScript disabled, or the field stripped — is rejected. Turn it on for a form that must stay usable without JavaScript; the check then only catches the demonstrably-too-fast submissions. The option is phrased as "allow" rather than "require" on purpose: its default is the unchecked state, so an empty checkbox in the form editor means the same thing as the option being absent.

On a multi-step form the timer restarts with every step render and per-page validation runs on every step, so minimumSeconds applies per displayed step — size it for one step, not for the whole form. Backward navigation still runs validation but its result is discarded by FormRuntime, so a quick Previous click cannot trap the visitor.

A rejected submission does not restart it, though. Both halves of the measurement — the client's stopwatch and the age of the challenge token — used to reset on the re-render that follows a validation error, so a visitor who had spent a minute on the form and then fixed a typo in three seconds was told they were too fast. On the site this was built for that hit ten separate people on one form. InjectFormChallenge now reissues the token with the original issue time and hands the already measured milliseconds back to the client, which adds its own on top. Nothing is softened by this: the carried value is capped at how long the token has actually existed, so a client cannot inflate it, and a bot submitting twice in a row still shows a token age near zero.

Both rejections are attached to the form root rather than to a field: there is no field to blame, and pointing a bot at the exact mechanism that caught it only helps whoever is tuning it.

The challenge rejects with two distinct messages and error codes, because the two causes need different things from the reader. The response field is rendered holding a sentinel (no-javascript) that the client overwrites with its answer; getting the sentinel back means no script ran, which is reported as errorMessageScriptMissing (code 1755648003) and says so — reload, allow scripts, try again. Any other unusable answer is a wrong one and keeps errorMessage (code 1755648001). The split also separates the two in the validation log, where "a real visitor has a blocker" and "a bot is knocking" were previously the same row.

Keeping PHP and JavaScript in sync

The five obfuscation transforms exist twice — in FormChallengeService::obfuscate() and in challenge.js. FormChallengeServiceTest::obfuscationMatchesTheJavaScriptImplementation() pins their exact output for a fixed input, so changing one side without the other fails a test instead of silently breaking every protected form.

Password policy JSON endpoint

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:

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 localized per request: the client appends ?lang=<code> (taken from document.documentElement.lang) and the middleware matches it against the site's languages by ISO code, hreflang or full locale, falling back to the site default. Without that parameter a multi-language site would label every rule in its default language.

The middleware is registered in Configuration/RequestMiddlewares.php after cms-frontend/site (so the site context is available) and before both cms-frontend/base-redirect-resolver and cms-frontend/page-resolver. The base-redirect-resolver ordering matters: the endpoint URL deliberately carries no language prefix, and that middleware 404s any path outside a configured language base — so on a site whose languages live under /de/ and /en/, the unprefixed URL (the only one the client ever requests) would otherwise never reach this endpoint. The path is matched by suffix, so a language-prefixed URL keeps working too.

Frontend rendering

The Password and AdvancedPassword elements render the indicator themselves — no template overrides needed — and gained these properties:

Property Default (Password / AdvancedPassword) Effect
showPasswordPolicy true / true Renders the live requirement list under the field.
passwordPolicyHeading 'Password must meet:' Heading above the list.
showPasswordToggle false / true Adds a button that reveals/masks the value (and the confirmation, on AdvancedPassword).
passwordToggleShowLabel / passwordToggleHideLabel 'Show' / 'Hide' Button labels for the two states.
showPasswordGenerator false / true Adds a button that fills in a random password satisfying the active policy, and reveals it.
passwordGeneratorLabel 'Generate password' Generator button label.

The toggle and generator default to off for Password, which is frequently a login or "current password" field, and on for AdvancedPassword, which always means "set a new password". All labels are per-element properties, so they translate through the normal form translation files. The JS and CSS are emitted only when at least one of the three features is enabled, so a plain password field stays asset-free; both degrade gracefully without JavaScript, since the server-side validator remains authoritative.

The generator mirrors the policy: one character is seeded from every required class, character pools omit visually ambiguous glyphs, randomness comes from crypto.getRandomValues() via rejection sampling, and the result is shuffled Fisher–Yates.

Site-sender feature (opt-in via extension flag)

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:

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):

When the feature flag is OFF, all listeners early-return and the decorators behave like the plain core data providers — zero runtime cost.

Validation-failure logging (opt-in per form)

Enable per form to track which fields fail validation most often — useful for drop-off analysis without storing any user-submitted values:

The RecordValidationFailures listener (consumes AfterFormIsValidatedEvent) writes one row to tx_form_validation_log per validation error with:

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:

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.

Outgoing-mail log (opt-in per form)

Answers the one question the form framework otherwise leaves open: did the notification mail actually go out?

The failure that prompted this is worth stating, because it shaped the design. On a live site a daily monitoring form failed on every run for over ten days with FinisherException: The option "senderAddress" must be set — and nothing raised its hand, because the thing that was broken was the mail monitoring. The failure existed only as a line in var/log, and a log without a reader is not monitoring.

What is recorded

One row per mail an Email finisher attempts, in tx_form_mail_log. The row is opened before the finisher runs and advanced as its outcome becomes known:

Status Meaning
PENDING The finisher started; the mail object does not exist yet.
PREPARED The mail is built and about to be handed to the transport.
SENT The transport accepted it.
FAILED A FinisherException was caught; error_code says which kind.

Opening the row first is the whole point. There are three failure classes, not one:

  1. A missing subject/recipients/senderAddress throws while EmailFinisher validates its options — before any mail object and therefore before any mail-specific event exists. This is the production case, and a log that started at MailBeforeSendingEvent would never have written a row for it.
  2. A transport error throws from send() and is wrapped as FinisherException 1754047320.
  3. Neither — an RFC-invalid sender address throws RfcComplianceException, a broken Fluid mail template surfaces inside send() because FluidEmail renders lazily, and OOM or a timeout throws nothing at all. None of these is caught anywhere.

Class 3 is why a row left in a non-terminal status is a feature: the trace exists, and the module reports it as outcome unknown rather than showing nothing. Whether a row counts as abandoned is derived from its age at query time (15 min grace), never written by a sweep task — a monitoring feature that only tells the truth once someone remembers to schedule a second task would lie until they did.

How a row is written

Four listeners in TYPO3\CMS\Form\EventListener\RecordMailDeliveries, all delegating to TYPO3\CMS\Form\Service\MailLogRecorder. Nothing is patched into EmailFinisher itself — the log is an observer, and switching it off leaves the send path untouched.

Event Recorder What enters the row
BeforeFinisherExecutedEvent (filtered to EmailFinisher) open() Opens it: form and finisher identifier, finisher class, site, page, language, submission id, resolved recipient_modePENDING
MailBeforeSendingEvent prepare() The mail object now exists: recipients and their count, transport name, attachment count, and — as far as the policy allows — subject, sender, reply-to → PREPARED
AfterMailSentEvent sent() Closes it: tstamp and the transport's message_idSENT
FinisherFailedEvent failed() Closes it: error_code, error_class, and the message if the policy allows it for that code → FAILED

FinisherFailedEvent is itself an addition of this fork — the third branch of the finisher event pair, dispatched from AbstractFinisher::execute()'s catch block. Without it a failing finisher had no terminal event at all.

The four events carry the finisher (or the mail), not a log id, so the row is tracked per spl_object_id($finisher) for the duration of the request. A form with several e-mail finishers therefore keeps its rows apart, and one submission_id — random per request — groups everything one submission sent.

Two details that decide whether the log can be trusted:

Configuration

Off by default. The master switch is the extension configuration:

Per form, and per finisher, via rendering options:

The per-finisher level is not decoration. "The recipient is our own inbox, so there is no personal data" is true for EmailToReceiver and false for EmailToSender, where the recipient is the visitor — one form-wide setting cannot be right for both.

The privacy design: columns are gated, not rows

A row carrying only form, finisher, status, error code and timestamps contains no personal data, so it needs no opt-in. Recipient, subject, sender and reply-to do, so those stay opt-in per form.

Gating the whole row instead is the obvious design and it is wrong: the form nobody watches is precisely the form nobody opts in. With row-level opt-in the broken monitoring form would have produced no rows and stayed invisible for a second ten days. Set mailLogAllForms = 0 if you want strict per-form opt-in anyway — same code, both policies.

Two consequences worth knowing:

Never stored, in any configuration: message body, submitted field values, CC/BCC (a "send me a copy" checkbox puts the visitor there), attachment filenames (only the count), IP, user agent. recipient_mode is stored alongside each row so old rows stay interpretable after a policy change — that is what makes the table auditable rather than merely small.

Reading it

Backend module Administration → Form log, next to the other operational logs. This is the first of its two views; the doc header switches to the validation statistics. Direct URL /typo3/module/form/log.

It sits on the second level on purpose, not inside Forms: TYPO3 remembers the last third-level module a user opened and makes it the landing page of its second-level parent. Registered under Forms, one visit to the log turned the Forms menu entry into the log permanently — and because the module menu renders only two levels, the form list was then left with no reachable entry point at all. A monitoring view must not be able to displace the thing it monitors.

Filters by date range (default: last 30 days), status and form; the status filter's Needs attention entry means "failed, or abandoned past the grace period" and shares its SQL with the CLI check below, so an alert and the screen you check it against cannot disagree.

For servers, where it matters more:

The second form is the one that closes the original incident: the monitoring cron gets a second line that checks the result of the first.

Limits

Periodic cleanup. TYPO3\CMS\Form\Task\CleanupMailLogTask, registered as Form: clean up mail log, reusing the same tx_form_retention_days field. Schedule it daily. This matters more than for the validation log: rows here can hold a recipient address, so retention is storage limitation under Art. 5(1)(e) GDPR, not housekeeping.


Consent log (opt-in, featureConsentLog)

One row per consent checkbox per submission: which consent, whether it was given, when, on which form and language — and the SHA-256 of the exact wording the visitor was shown.

Why it exists. Art. 7(1) GDPR asks the controller to be able to demonstrate that the data subject consented. On a typical contact or trial-request form the only finisher is an e-mail one, which makes the notification mail the sole trace of the submission — and a mailbox is mutable, prunable, and silent about which version of the consent text was on screen. Printing dsgvocheckbox-1: 1 into that mail was never evidence; it only looked like it.

What is recorded. tx_form_consent_log holds the facts, tx_form_consent_text the wordings, addressed by hash and written once per distinct text. Normalised because the same paragraph repeats on every submission, and because "which versions have we ever shown" then costs one query. Editing a consent text mints a new hash and leaves every earlier record pointing at what was actually displayed.

The wording is resolved through TranslationService::translateFormElementValue(), not $element->getLabel(). The latter returns the default-language text, which would put a German visitor on record as having agreed to the English paragraph — a consent record showing the wrong wording is worse than none, because it reads as authoritative.

The one personal datum is subject: an identifying value from the submission, so a record can be produced for a named person. A form names its field through renderingOptions.consentLog.subjectField (identifier, or a comma-separated list tried in order); otherwise the usual e-mail identifiers are guessed. If none matches, the consent is still recorded — anonymously, which beats guessing a random text field into an evidence column. renderingOptions.consentLog.enabled: false opts a form out entirely.

Not part of EmailFinisher, deliberately: consent belongs to the submission. A form that only writes to the database owes the same demonstration, and a form with two e-mail finishers must not record the consent twice. The listener sits on BeforeFinisherExecutedEvent — the earliest point that means "this submission passed validation and is being processed" — and deduplicates on the submission id.

Correlating with the mail log. Both logs take their submission_id from the shared SubmissionIdProvider, so "consent given" and "notification sent" join on one column:

Reading it. Third view of the form log module (Administration → Form log), with a search by person: type an address, get every consent that person gave and the wording they saw. A log only a DBA can read does not satisfy "shall be able to demonstrate" in any practical sense — the person answering a subject access request is a DPO, not someone with SQL on production.

Limits, in the same spirit as the mail log's:

Periodic cleanup. TYPO3\CMS\Form\Task\CleanupConsentLogTask, registered as Form: clean up consent log, reusing tx_form_retention_days, and dropping wordings nothing refers to any more after each run. Its default is 1095 days, not the 90 of its siblings, and the difference is the whole point: pruning evidence on a monitoring-log schedule destroys exactly the record the log exists to keep. Three years is the German regelmäßige Verjährungsfrist (§ 195 BGB) used as a starting point, not advice — the right window follows from the purpose the consent was given for. "Keep forever" is not the safe option it looks like either, because subject is personal data and Art. 5(1)(e) still applies.


Fork maintenance

Branch layout

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 sync workflow

Upstream is registered as the upstream remote (https://github.com/TYPO3-CMS/form.git). Picking up new upstream work is automated: .github/workflows/upstream-sync.yml (daily at 06:00 UTC, plus workflow_dispatch) runs .github/scripts/upstream-sync.sh, which proposes each pending upstream commit as its own pull request — never a branch merge.

Merging a reviewed PR is a plain gh pr merge <n> --merge. Never merge upstream/14.3 wholesale into release/v14 — the point of the per-commit PRs is that each merge commit carries exactly one upstream change, so the fork history stays grep-able and our changes stay visible without upstream noise mixed in.

Two things worth checking before merging an [upstream] PR:

The mirror branches are still pushed by hand:

Upstream tags are deliberately not pushed to origin. The fork numbers its own releases in the same v14.3.x space, so a mirrored core tag and a fork release would claim the same name while pointing at entirely different commits — and Packagist, which normalises 14.3.7 and v14.3.7 to the same version, would resolve one of them arbitrarily. In this repository v14.3.x therefore always means a fork release; the core's own tags stay one git fetch upstream --tags away on the upstream remote. Do not re-add git push origin --tags here.

Cherry-picking manually stays the fallback when the bot is not involved:

When a new TYPO3 minor (e.g. 14.4) lands upstream, point the workflow's upstream_branch input at it, cherry-pick the relevant commits up to that tag and adjust the branch-alias in composer.json.

Releasing

Tags are annotated and always carry the v prefix (v14.3.9, not 14.3.9) — the mixed spelling that existed until 2026-08-21 has been normalised, so every published version keeps its commit but is now spelled v…. The tag message summarises what changed since the previous release and, where it applies, which upstream PRs were deliberately not merged and why.

Conventions for additions


Changelog

Fork-relevant changes only, newest first. The fork ships unversioned from release/v14 (installed as dev-release/v14 as 14.3), so entries are grouped by month rather than by release tag. Everything before the fork point is TYPO3's own history — see the upstream changelog for that. Short SHAs are on release/v14; #n refers to a pull request in WapplerSystems/form.

2026-08

Added

Changed

Fixed

Upstream

2026-07

2026-06


Adding an entry. One bullet per change a user or integrator would notice, in the current month's block, with the short SHA in backticks at the end. Group fork work under Added / Changed / Fixed once a month has more than a handful of entries, and keep merged [upstream] PRs in their own Upstream block, referenced by PR number — a reader wants to tell fork decisions from upstream ones at a glance. Pure test, CI and documentation commits are not listed unless they change how the extension is used; note held-back upstream PRs and why, so the reason survives longer than the pull-request thread.


All versions of form with dependencies

PHP Build Version
Package Version
Requires psr/http-message Version ^1.1 || ^2.0
symfony/expression-language Version ^7.4.8
typo3/cms-core Version 14.3.*@dev
typo3/cms-frontend Version 14.3.*@dev
Composer command for our command line client (download client) This client runs in each environment. You don't need a specific PHP version etc. The first 20 API calls are free. Standard composer command

The package wapplersystems/form contains the following files

Loading the files please wait ...