MODX forms without a page reload, protected from spam out of the box.
English | Русский
FetchIt submits the forms of a MODX site with the Fetch API: no page reload, no jQuery or other libraries. The forms are processed by FormIt, with all its hooks and validation, or by a snippet of your own. Every form is protected from spam from the start: a single-use token, a fill time, a trap field and a limit of submissions, and a proof of work and a captcha when you need them. One package for MODX 2.8 and MODX 3.
- Features
- Installation
- Quick start
- Snippet properties
- Spam protection
- Notifications
- Events and JavaScript
- TypeScript
- System settings
- Upgrading to FetchIt 4
- Requirements
- Development
- License
- FormIt out of the box. Properties such as
&hooks,&validateand&emailTogo to FormIt as they are, with no wrappers. Instead of FormIt you can name a snippet of your own that returns JSON (success,message,data). - Your own markup. All it takes is a form chunk with attributes:
data-errorfor the errors of fields,data-successanddata-validation-errorfor the messages of the form. The classes of invalid fields are system settings. - Works without JavaScript too. With FormIt the form is sent the usual way, and the messages and the values entered come back through its placeholders. Unless a proof of work or a captcha is on: they need JavaScript.
- Spam protection by default: a signed single-use token, a minimum fill time, a hidden trap field and a limit of submissions. Optionally a proof of work and a captcha: Cloudflare Turnstile, Google reCAPTCHA v3 or Yandex SmartCaptcha. Plugins add rules of their own.
- No dependencies. The script needs no jQuery or other libraries and uses the native Fetch API and
FormData(files included). It is loaded withdeferand loads no other files, except the script of the captcha provider when a captcha is on. - Built-in notifications, or your own through
FetchIt.Message: Bootstrap, SweetAlert2, anything. - Events
fetchit:before,fetchit:after,fetchit:success,fetchit:error,fetchit:reset: add data, cancel a submission, show a modal. - Redirect after a submission:
&redirectTo, or a redirect made by your own snippet or a FormIt hook, goes to the browser as a part of the answer. - Timeout and retries:
&timeoutand&retriesresend the form after a network failure, with one submission key, so a lost answer is not processed twice; the eventfetchit:retrycan stop a retry. - TypeScript types ship next to the script.
- Several forms on a page, each with its own key and handler, as long as the snippet calls differ in their properties.
- Fenom and
@FILEchunks through pdoTools on MODX 2 and MODX 3.
FetchIt is free. Install it with the Package Manager from the official modx.com repository or from the modstore.pro marketplace (how to add the repository). When FormIt is not installed, the installer downloads it from modx.com; if that fails, the install log says so, and FormIt has to be installed by hand.
You can also build the package from this repository, see Development.
Call the snippet where the form goes. The call must be uncached ([[!FetchIt]]): every output of the form has a token of its own, and the script is added only when the snippet ran in the request.
[[!FetchIt?
&form=`myForm.tpl`
&hooks=`email`
&emailTo=`info@example.com`
&emailSubject=`A request from the site`
&validate=`name:required,email:email:required`
&successMessage=`Your message has been sent`
]]
The chunk myForm.tpl, a plain form with the attributes of FetchIt:
<form action="[[~[[*id]]]]" method="post">
<label>Name
<input type="text" name="name" value="[[+fi.name]]">
<span data-error="name">[[+fi.error.name]]</span>
</label>
<label>Email
<input type="email" name="email" value="[[+fi.email]]">
<span data-error="email">[[+fi.error.email]]</span>
</label>
<button type="submit">Send</button>
<div data-success style="display: [[+fi.success:is=`1`:then=``:else=`none`]]">[[+fi.successMessage]]</div>
<div data-validation-error style="display: [[+fi.validation_error:is=`1`:then=``:else=`none`]]">[[+fi.validation_error_message]]</div>
</form>data-error="name"gets the error of the fieldname, and the field itself getsaria-invalidand the class fromfetchit.frontend.input.invalid.class.data-custom="name"(optional) gets the class fromfetchit.frontend.custom.invalid.class: the wrapper of the field, for example.data-successanddata-validation-errorshow the message of the form.- The
[[+fi.…]]placeholders are for submissions without JavaScript. If those do not matter, leave them out.
FetchIt adds the data-fetchit key, the config of the form (data-fetchit-config) and the service fields of the protection to the form, and adds the script to the page. The chunk tpl.FetchIt.example is a ready example.
The documentation (in Russian) has more examples: markup, notifications, modals, client-side validation, the JS API.
| Property | Default | What it does |
|---|---|---|
form |
tpl.FetchIt.example |
The chunk of the form. With pdoTools: @FILE, @INLINE and Fenom |
snippet |
FormIt |
The snippet that processes the form, see below |
actionUrl |
[[+assetsUrl]]action.php |
Where the form is sent |
clearFieldsOnSuccess |
1 |
Clear the fields after a successful submission |
formState |
1 |
Put the state of the submission into data-fetchit-state of the form |
ariaBusy |
1 |
Put aria-busy="true" on the form while it is sent |
redirectTo |
Where to go after a successful submission: a resource id or an address. For FormIt it is the property of its redirect hook, see Redirect after a submission |
|
redirectParams |
The parameters of the redirectTo address, a JSON object |
|
timeout |
0 |
How many seconds to wait for the answer to one attempt; 0 means no limit |
retries |
0 |
How many times to send the form again after a network error, a timeout, a 502/503/504 of a proxy or while the form is still processed, 0 to 5, see Reliable submission |
All other properties go to the processing snippet: for FormIt, &hooks, &validate, &emailTo, &successMessage, &placeholderPrefix and the rest.
The FetchItScript snippet has no properties: [[!FetchItScript]] adds the script to a page whose forms come later, see Things to keep in mind.
A snippet of your own gets the fields sent in $fields (without the service fields of the protection) and answers through FetchIt:
$FetchIt = FetchIt::service($modx);
if (empty($fields)) {
// The form is being output, not sent: the snippet runs on every output too.
return '';
}
if (empty($fields['email'])) {
return $FetchIt->error('Please check the form', ['email' => 'Enter your email']);
}
// ... save it, send an email
return $FetchIt->success('Thank you, we got your request');The empty($fields) check is a must: the snippet also runs on every output of the form, with an empty $fields, and without the check it would, for example, send an empty email on every page view. A form sent without JavaScript does not show what the snippet returns: a message appears on the page only if the snippet sets placeholders itself.
After a successful submission FetchIt can take the visitor to another page:
- FormIt: the
redirecthook and&redirectTowork as usual:[[!FetchIt? &hooks=email,redirect&redirectTo=15]]. Before, such a form showed "could not send" though the email went out. - A snippet of your own:
&redirectTo(a resource id or an address) and&redirectParams(a JSON object with the parameters of the address) in the FetchIt call; the server addsredirectto a successful answer. Your snippet may also call$modx->sendRedirect()itself: like theredirecthook, it reaches the script as a successful answer with the address, not as a redirect to HTML.
The script goes there after the fetchit:success event; the address is in event.detail.redirect (not in event.detail.response), and cancelling the event keeps the visitor on the page. It goes through the instance method navigate(url), which you can override for a router of your own. The script goes only to http: and https: addresses, resolves relative ones against the page, and does not count a change of only the hash of the same page as leaving: the form is given back. While the page is being left, the form stays disabled; if the visitor comes back with the Back button and the browser restores the page from its cache, the form is given back. navigate() must return false for a target that does not leave the page (a file download with Content-Disposition: attachment, a 204): otherwise the form stays disabled. A site with such a target overrides navigate(), for example to download through a hidden iframe:
const instance = FetchIt.instances.get(form);
const navigate = instance.navigate.bind(instance);
instance.navigate = (url) => {
if (!url.includes('/download/')) return navigate(url);
const frame = document.createElement('iframe');
frame.hidden = true;
frame.src = url;
document.body.appendChild(frame);
return false;
};Without JavaScript a snippet of your own with &redirectTo shows its answer on the same page.
On a poor connection &timeout limits the wait for the answer to one attempt (reading its body included), and &retries sends the form again after a network error, a timeout or a 502/503/504 of a proxy that is not a FetchIt answer:
[[!FetchIt? &form=`myForm` &timeout=`15` &retries=`2`]]
A retry does not process the form twice, except when the protection store cannot be written (the cause is logged): then the form is processed without the key. Each submission carries a key (the X-FetchIt-Submission header), and the server keeps the answer to it for an hour in the protection store (fetchit.protection.store: files in core/cache/fetchit/answers/ or the fetchit_store table). If the first attempt reached the server and the answer was lost, the retry gets the same answer. If the first one is still being processed, the retry is refused with HTTP 409 and X-FetchIt-Refused: pending (the message fetchit_err_pending) and waits. The pauses between attempts are 1, 2 and 4 seconds, then 8 seconds each. A FetchIt answer and other errors are not retried. The cancelable event fetchit:retry (detail has attempt, the number of the retry from 1, and error) comes before each retry; a destroyed form sends no retry.
If the last attempt timed out, the visitor sees fetchit_err_timeout: the form may have been sent. When the visitor sends the form again with the same data after a message that it got no answer (the timeout, a network error, fetchit_err_pending), it goes with the same key, so a slow server does not process it twice; a changed or reset form is a new submission with a new key. timeout and retries are off by default, and forms are sent as before.
Every FetchIt form is protected with no setup. The checks run before FormIt or your snippet, both for FetchIt submissions and for forms sent without JavaScript:
- Token. The form gets a hidden
fetchit_tokenfield with a signature of the key of the form, the time it was output and a random number. A token is used once and needs no session. A new token is given only in answer to a well-signed token of the same form, so a bot cannot send the form without loading the page. The answer to a submission with a well-signed token brings the next token (except a refusal for the limit), so the form can be sent again without a reload. - Fill time. A form sent sooner than
fetchit.protection.min_timeseconds after the page was output (3 by default) is refused. A second submission counts from the same output, so a person who fixed a field after an error is not refused. - Trap. A hidden field with a random name per installation, which people do not see and browser autofill does not recognise. When it is filled in, the bot gets a success answer with the
successMessageof the form, but the form is not processed and no email goes out. Such cases are logged. - Limit. At most
fetchit.protection.rate_limitsubmissions of a form from one address withinfetchit.protection.rate_windowseconds (10 in 10 minutes by default). Every attempt with a well-signed token counts, refused ones too, so replaying an old token does not get around the limit.
The service fields are removed from $_POST before FormIt runs and never reach emails. The signing key is generated when the package is installed (fetchit.protection.secret).
Both are off by default and help when spam gets through the main checks. They work only with the protection on (fetchit.protection).
Proof of work. fetchit.protection.pow sets the difficulty in bits: 0 turns it off, 24 is the most (a larger value or one that is not a number is logged and replaced). Before sending, the browser looks for a number n such that SHA-256 of token:n starts with that many zero bits, and sends it in the fetchit_pow field. Solving starts as soon as the visitor enters the form, so the solution is usually ready by the time they press the button, while every submission costs a bot processor time. 16 bits take about 65 thousand hashes on average: about a third of a second on a computer, several times longer on a phone. Every bit doubles the time, 20 bits take about 16 times longer. When a page from a cache asks for less than the server, FetchIt solves again and sends the form once more by itself. With a proof of work on, a form cannot be sent without JavaScript.
Captcha. Set fetchit.captcha to turnstile (Cloudflare Turnstile), recaptcha (Google reCAPTCHA v3) or smartcaptcha (Yandex SmartCaptcha), and fetchit.captcha.site_key and fetchit.captcha.secret_key to the keys from the provider's dashboard. Until both keys are set, the captcha stays off and the log says what is missing (the same for a misspelt provider). FetchIt adds the provider's script and gets its answer before sending:
- Turnstile shows its widget in a
.fetchit-captchablock before the first button withtype="submit", or at the end of the form when there is none; - reCAPTCHA v3 has no widget: an answer is asked for each submission, with the action
fetchit. Answers scoring belowfetchit.captcha.min_score(0.5 by default) are refused; - SmartCaptcha works invisibly and shows a puzzle only when in doubt.
The server checks the answer last among the checks of the protection (OnFetchItBeforeProcess plugins run after it), so bots without a token never reach the provider. When the provider refuses the answer, the visitor sees fetchit_err_captcha. When the provider does not answer, answers with an error or does not accept the secret key, the form is refused too, but with fetchit_err_captcha_unavailable ("the check is unavailable right now"), and the cause is logged. When the provider's script did not load (an ad blocker, CSP), the visitor closed the puzzle or the widget failed, the form is not sent and the visitor sees fetchit_err_captcha_client. The captcha works only through FetchIt: a form sent without JavaScript does not pass it.
For a development site Turnstile has test keys: site 1x00000000000000000000AA, secret 1x0000000000000000000000000000000AA.
A refusal comes as an ordinary error of the form: the lexicon message fetchit_err_token (the form has expired), fetchit_err_too_fast, fetchit_err_rate, fetchit_err_store, fetchit_err_pow, fetchit_err_captcha or fetchit_err_captcha_unavailable, the fetchit:error event and a notification. fetchit_err_pending and fetchit_err_timeout are not refusals: they tell about a retry, see Reliable submission. When the token of the page is stale (a page from a cache, a page open for long, a new key), FetchIt sends the form once more with a new token by itself, and the visitor notices nothing. Without JavaScript the message goes to [[+fi.validation_error_message]], and the values entered are kept, as in the tpl.FetchIt.example chunk.
- A full-page cache (nginx, a CDN, static cache plugins). By default, pages with forms must be left out of such a cache: the properties of a form are kept in the session of the visitor the page was output for, and a visitor who gets the page from the cache sees "form not found". Turn on
fetchit.page_cacheand forms work on pages from the cache: the properties of the forms are kept in the protection store for 90 days after the page was last rendered (not served from the cache), and on the first focus on a form, or before the first submission if there was none, the script exchanges the token of the page for a fresh one with one light request, so the form goes with an unused token and is not refused and resent; exchanges have a rate limit of their own. After the exchange the script waitsfetchit.protection.min_timeplus a quarter of a second before it sends the form, so on several servers keep their clocks synchronised (NTP). After turning it on, clear the page cache and keep pages in it for less than 90 days. A page from the cache carries one token for all its visitors: without JavaScript only the first submission with it passes (untilfetchit.protection.token_ttlruns out), the rest are refused. The token of any page that was ever cached can be exchanged untilfetchit.protection.secretis changed; changing the key revokes all tokens. When the properties of a form depend on the request (for example[[!+get.x]]), each combination takes a record of its own. - FormIt 5.2 and later can send forms over AJAX itself. For FetchIt forms that mode is turned off: FetchIt removes the properties FormIt stored, the
fi.ajaxTokenplaceholder and theformit.jsscript, as otherwise the form could be sent through FormIt'saction.php, around the protection. Forms that[[!FormIt]]itself outputs on the same page keep their AJAX mode. The reCAPTCHA of FormIt 5.2 gets its answer throughformit.js, so it does not work in FetchIt forms: usefetchit.captchainstead. Captcha hooks that read the answer from their field themselves work, as long asfetchit.captchais not set to the same provider. - Forms loaded later (a modal loaded over AJAX, pdoPage, mFilter2) work like any other: every form carries its config in
data-fetchit-config, and the script picks up inserted forms and forgets removed ones. The HTML must come from an uncached[[!FetchIt]]call, so the form has its token. A page that has no FetchIt form of its own and gets one later needs the script: put[[!FetchItScript]]into its template. A cached[[FetchItScript]]works too: it leaves the marker<!--FetchItScript-->in the cached page, and the plugin adds the script on every view and takes the marker out. Put the call into the markup of the page, not into an attribute or behind an output filter that escapes it; a plugin that strips HTML comments must run after FetchIt's, which runs at priority -10. A form inserted by your own script is picked up a moment later; callFetchIt.init(element)to have it at once. The form is processed in the context of the resource that output it: for a fragment, the fragment's page (pageId). - A script of your own instead of the bundled one must send the form to
action.phpwith anX-FetchIt-Actionheader equal to thedata-fetchitattribute of the form, and apageIdfield with the id of the page. It must send thefetchit_tokenfield of the form and take the next token from theX-FetchIt-Tokenheader of every answer. When an answer comes withX-FetchIt-Refused: token, send the form again with the new token. With a proof of work, send infetchit_powa solution for the token sent in that request, and solve again for a resend. Apowrefusal tells the difficulty in theX-FetchIt-Powheader. With a captcha, send the provider's answer in its field (cf-turnstile-response,g-recaptcha-responseorsmart-token), and execute reCAPTCHA v3 with the actionfetchit. Withfetchit.page_cache, exchange the token before the first submission (best on the first focus, so the fill time passes while the visitor types): send a POST toaction.phpwith the headersX-FetchIt-ActionandX-FetchIt-Exchange: 1and the fieldsfetchit_tokenandpageId: the answer is 204 with the fresh token inX-FetchIt-Token, the difficulty inX-FetchIt-Powand the fill time in seconds inX-FetchIt-Min-Time, or withX-FetchIt-Refused: token,rateorstoreand no token (then keep the token of the page); withoutfetchit.page_cachethe answer is 204 with neither a token nor a refusal. Send the form with the fresh token only after the fill time has passed since the exchange. Atokenrefusal may also carryX-FetchIt-Min-Time: wait that long before the resend. Exchanges have a rate limit of their own, with the samefetchit.protection.rate_limitandfetchit.protection.rate_window. - Behind a proxy or a CDN all visitors come from one address, and the limit becomes one for the whole site. List the addresses of the proxies in
fetchit.protection.proxies(IPs or CIDR ranges, separated by commas) and the header with the visitor's address infetchit.protection.ip_header(X-Forwarded-For,CF-Connecting-IP). - The state of the protection (used tokens, rate counters, the forms of
fetchit.page_cache, the answers kept for retries for an hour) is kept in files by default: the marks incore/cache/fetchit/tokens/, the forms incore/cache/fetchit/forms/, the answers incore/cache/fetchit/answers/, which "Clear cache" leaves alone; the rate counters are in the MODX cache, which "Clear cache" resets. When the directory is not writable, forms are refused and the cause is logged. On several servers setfetchit.protection.storetodb: all of it moves to thefetchit_storetable of the site's database (the installer creates it; it works on MySQL, MariaDB and SQL Server), and the clocks of the servers must be in sync. Switching does not carry the marks, the forms and the answers over: a token used before the switch can be sent once more withintoken_ttl, a retry of a submission made before the switch is processed again, and forms on pages from the old cache work after the page is rendered again, so switch at a quiet time and then clear the page cache. - The log:
fetchit.protection.log0 turns it off, 1 (the default) logs problems and refusals that may hit people (the trap, the limit, writing the marks, a captcha provider that is unavailable), 2 logs every refusal, answers the captcha provider refused included. Mistakes in the settings of the captcha and the proof of work, and the problems of thefetchit_storetable are always logged. - On a development site and in automated tests, set
fetchit.protection.min_timeandfetchit.protection.rate_limitto 0. Turn the whole protection (fetchit.protection) off only for debugging.
A plugin on the OnFetchItBeforeProcess event gets $action, $fields (the fields sent, without the service fields and files), $properties (may be null) and $FetchIt. To refuse a submission, it outputs a message for the visitor or a lexicon key through $modx->event->output(). Returning a string with return does not refuse it: MODX only logs it.
// A plugin on OnFetchItBeforeProcess
$email = isset($fields['email']) && is_string($fields['email']) ? $fields['email'] : '';
if (preg_match('/@(mailinator|tempmail)\./i', $email)) {
$modx->event->output('Disposable addresses are not accepted.');
}The event also fires with the protection off.
With fetchit.frontend.default.notifier, the answers of the server and failed submissions are also shown as notifications in a corner of the page. The message is shown as text: tags are removed from it, HTML entities such as & stay as they are.
- Screen readers hear the notifications from two hidden live regions that are on the page from the start: an error at once (
role="alert"), a success when the reader is idle (role="status"). - A notification closes with its button or by itself after 6 seconds. While the pointer or the focus is on it, the countdown stops, and then starts over. When a notification is closed from the keyboard, the focus moves to the next notification or back to where it came from.
- At most three are shown at a time. The one with the focus is never the one dropped.
The styles come first in <head>, with single class selectors. They win over the site's rules for bare elements (button { … }) and lose to any rule of the site with a class. Rules inside @layer (in Tailwind 4, for example) lose to them: for those, change the variables. By default they are pastel colours of Tailwind CSS 4, as in its alerts: the 100 shade for the background, 200 for the border, 800 for the text (a text contrast of about 6.5:1, well above WCAG AA). Where the browser knows oklch() they are exact, elsewhere in hex:
.fetchit-toasts {
--fetchit-toast-success-bg: oklch(96.2% 0.044 156.743); /* green-100, #dcfce7 */
--fetchit-toast-success-border: oklch(92.5% 0.084 155.995); /* green-200, #b9f8cf */
--fetchit-toast-success-text: oklch(44.8% 0.119 151.328); /* green-800, #016630 */
--fetchit-toast-error-bg: oklch(93.6% 0.032 17.717); /* red-100, #ffe2e2 */
--fetchit-toast-error-border: oklch(88.5% 0.062 18.334); /* red-200, #ffc9c9 */
--fetchit-toast-error-text: oklch(44.4% 0.177 26.899); /* red-800, #9f0712 */
}A site on Tailwind 4 can take its own variables, for example --fetchit-toast-success-bg: var(--color-emerald-100).
When the site's Content-Security-Policy forbids inline styles (style-src without 'unsafe-inline'), give the FetchIt script a nonce: the styles get the same one. Otherwise FetchIt warns in the console, and the notifications need styles of your own.
A FetchIt.Message of your own wins over the setting. When it has neither success nor error (only a spinner in before and after, for example), the built-in notifications add them, provided FetchIt.Message is set before DOMContentLoaded: right in a deferred script added after fetchit.js. You can also turn the notifications on from your own script, with another label for the button or another time on screen (0 means until closed): FetchIt.Message = FetchIt.createNotifier({ closeLabel: 'Close', duration: 4000 }).
Before FetchIt 4 the setting loaded the Notyf library. It is gone: styles for .notyf__toast and scripts that call new Notyf() need to change, or load Notyf themselves.
The events are dispatched on document and do not bubble. Their event.detail holds the form (form), its data (formData) and the FetchIt instance (fetchit), and from fetchit:after on the answer of the server (response). A fetchit:error without an answer has the cause (error); fetchit:reset has only form and fetchit.
| Event | When | Cancelling it (event.preventDefault()) |
|---|---|---|
fetchit:before |
before sending; fields can be added to formData |
the form is not sent |
fetchit:after |
a FetchIt answer came | the answer is not handled: no field errors, no message, no notification, no fetchit:success or fetchit:error, no clearing |
fetchit:success |
the form was accepted; the address to go to, if any, is in detail.redirect |
the fields are not cleared, no redirect |
fetchit:retry |
before a retry of the submission (&retries); detail also has attempt (the number of the retry, from 1) and error |
the retry is not sent, the visitor sees the error |
fetchit:error |
the form was refused or could not be sent; response is null when no FetchIt answer came, the cause is in error |
the field errors and the message of the form are not shown |
fetchit:reset |
the form is being reset: its button, form.reset() or after a success; the fields still hold their values |
not possible |
document.addEventListener('fetchit:before', ({ detail }) => {
detail.formData.append('page', location.pathname)
})
document.addEventListener('fetchit:success', ({ detail }) => {
gtag('event', 'form_submit', { form_id: detail.form.id })
})FetchIt.Message takes the hooks before, after(message), success(message), error(message) and reset. They run before the event of the same moment (except reset, which runs after fetchit:reset), so cancelling the event does not undo them:
FetchIt.Message = {
success (message) { Swal.fire({ icon: 'success', text: message }) },
error (message) { Swal.fire({ icon: 'error', text: message }) },
}The global FetchIt exists once the deferred fetchit.js has run, so set FetchIt.Message from a deferred script added after it, or on DOMContentLoaded. The instance of a form is FetchIt.instances.get(form), with setError(name, message), clearErrors(), setFormMessage(type, message), navigate(url) and other methods.
The page starts FetchIt with FetchIt.observe() on DOMContentLoaded. It handles the forms on the page and those inserted later, and calls destroy() on forms taken out of the page: the instance leaves FetchIt.instances, its proof of work stops, its captcha widget is removed. FetchIt.init(root) handles the forms in root right away, with the class that observes the page. A form whose config is broken or that cannot start is logged in the console and sent the usual way; the other forms start all the same. A form removed while it is being sent ends quietly, without an error message. FetchIt.create(config) still works for pages cached by FetchIt 4.0. A class of your own in fetchit.frontend.js.classname with a create() of its own gets a create(config) call per form key first, as in FetchIt 4.0, and then observe(); a script of your own without observe() gets only the create() calls, for the forms on the page when it loads.
While a form is sent, it has data-fetchit-state="sending" and aria-busy="true"; after the answer, data-fetchit-state is success or error (a refusal of the server or a failed request), and aria-busy is gone. The state is set before fetchit:after, fetchit:error and fetchit:success, so it holds when a listener cancels them. A reset of the form removes it, except while the form is sent; the reset of clearFieldsOnSuccess keeps success. Styles need only CSS:
form[data-fetchit-state="sending"] button[type="submit"] {
cursor: progress;
opacity: .6;
}&formState=0 and &ariaBusy=0 turn them off for a form. CSS that draws a spinner on any element with [aria-busy="true"] (Pico CSS 1 and 2.0, for example; Pico 2.1 leaves forms out) draws it over the whole form: turn &ariaBusy off there if that is not wanted.
The types are in assets/components/fetchit/js/fetchit.d.ts. Copy the file into your project or reference it:
/// <reference path="../assets/components/fetchit/js/fetchit.d.ts" />
document.addEventListener('fetchit:error', event => {
// response is null when no FetchIt answer came: the network, a foreign answer, a captcha without an answer
if (event.detail.response === null) {
console.error(event.detail.error)
}
})
const form = document.querySelector('form')
if (form) {
FetchIt.instances.get(form)?.setError('email', 'Check the address')
}The script is type-checked against the same file: FetchIt with the instances of forms, and the detail of every event, so the types and the code cannot drift apart. Remove your own declaration of FetchIt if you had one (declare var FetchIt: any): the two would clash.
All settings are in the fetchit namespace.
| Setting | Default | What it does |
|---|---|---|
fetchit.frontend.js |
[[+assetsUrl]]js/fetchit.js |
The FetchIt script. The minified js/fetchit.min.js is about 19 KB instead of 36 (about 7 KB instead of 10 gzipped) |
fetchit.frontend.js.classname |
FetchIt |
The class that handles the forms, when you extended the bundled one |
fetchit.frontend.input.invalid.class |
is-invalid |
The class of an invalid field |
fetchit.frontend.custom.invalid.class |
The class for the data-custom elements of an invalid field |
|
fetchit.frontend.default.notifier |
No |
Show the answers as built-in notifications |
fetchit.protection |
Yes |
Spam protection. Turn it off only for debugging |
fetchit.protection.secret |
random | The key that signs the tokens, generated at install |
fetchit.protection.min_time |
3 |
The minimum fill time, seconds; 0 turns it off |
fetchit.protection.token_ttl |
86400 |
How many seconds a page with a form stays valid; 0 and anything above 30 days mean 30 days |
fetchit.protection.rate_limit |
10 |
Submissions of a form from one address within the window; 0 turns it off |
fetchit.protection.rate_window |
600 |
The window of the limit, seconds; 0 turns the limit off |
fetchit.protection.log |
1 |
The log of the protection: 0 nothing, 1 problems, 2 every refusal |
fetchit.protection.proxies |
Trusted proxies or CDNs, IPs or CIDR ranges separated by commas | |
fetchit.protection.ip_header |
X-Forwarded-For |
The header with the visitor's address from a trusted proxy |
fetchit.protection.pow |
0 |
The difficulty of the proof of work, bits; 0 turns it off, 24 at most |
fetchit.page_cache |
No |
A full-page cache serves the pages with forms, see above |
fetchit.protection.store |
files |
Where the state of the protection is kept. files: used tokens, the forms of fetchit.page_cache and the answers kept for retries (1 hour) in core/cache/fetchit/ (kept on "Clear cache"), rate counters in the MODX cache (reset by it); one server. db: all of it in the table fetchit_store, shared by several servers with synchronised clocks. Empty means files; any other value refuses the forms and is logged |
fetchit.captcha |
turnstile, recaptcha or smartcaptcha; empty for none |
|
fetchit.captcha.site_key |
The site key from the captcha provider | |
fetchit.captcha.secret_key |
The secret key from the captcha provider | |
fetchit.captcha.min_score |
0.5 |
reCAPTCHA v3: the minimum score, from 0 to 1 |
FetchIt 4 is one package for MODX 2.8 and MODX 3 instead of the 1.x and 3.x lines. Install it over the version you have with the Package Manager: the system settings and chunks stay as they are. The FetchIt snippet and plugin are replaced, together with the default properties of the snippet, so changes to their code and properties are lost; the snippet calls on your pages need no changes. CI checks the upgrade from 1.1.3 on MODX 2 and from 3.1.4 on MODX 3.
The API of the earlier versions works on both MODX versions:
- in your snippets, get FetchIt with
$FetchIt = FetchIt::service($modx);. The 1.x call ($modx->getService('fetchit', 'FetchIt', MODX_CORE_PATH . 'components/fetchit/model/')) and the 3.x one ($modx->services->get('FetchIt'), MODX 3 only) return the same object; - the 3.x class
FetchIt\FetchItis the same class,instanceof \FetchIt\FetchItworks.get_class()now returnsFetchIt; storeActionProperties()/loadActionProperties()and their 3.x namessaveActionProperties()/getActionProperties();- chunks through pdoTools (Fenom,
@FILE) on MODX 2 and MODX 3.
What may affect your code:
- forms get the service fields of the protection; a script of your own instead of the bundled one must send the token (see Things to keep in mind);
- the notifications setting no longer loads Notyf (see Notifications);
fetchit:erroralso fires when a request fails;detail.responseis thennull, and the error is indetail.error;- the processing snippet gets only the form sent in
fields:$_POSTand the files from$_FILESwhen sent through FetchIt, only$_POSTfor a form sent the usual way; no GET values and no cookies; fetchit:successcan be cancelled:event.preventDefault()keeps the fields filled;- sending again while a request is running is ignored;
methodanddata-fetchitnow come last among the attributes of the form tag.
The full list of changes is in the changelog.
- MODX 2.8 or MODX 3 (checked on 2.8.6 and 3.2.4).
- PHP 7.4 or later. The syntax and PHPUnit are checked on PHP 7.4 to 8.4, the install and form submissions on MODX on PHP 7.4 and 8.3.
- FormIt, when it processes the forms; it is installed with FetchIt.
- pdoTools, for form chunks on Fenom or in files.
- The browsers of
browserslistinpackage.json(current versions of every major browser): the script is built with ES2019 syntax, and CI checks it. Without JavaScript, forms with FormIt work the usual way, unless a proof of work or a captcha is on.
You need Node.js 22.22+, 24.15+ or 26+ (the CI version is in .node-version), PHP 7.4+ with Composer, and Docker.
npm ci && composer install
npm run build # src/ → assets/components/fetchit/js/: the ES2019 script and fetchit.d.ts
npm run check:syntax # the built script parses as ES2019
npm run lint # oxlint
npm run typecheck # tsc: the code, and the built public types as a site sees them (tests/types, after npm run build)
npm test # Vitest
vendor/bin/phpunit # PHPUnit
npm run screenshots # screenshots of the notifications in docs/images/, after changing their styles
npm run logo # the logo, the square mark, the pictures for modstore.pro and extras.modx.com and the social previews in .github/logo/ (needs the network for Google Fonts)Two sites, MODX 2.8.6 on PHP 7.4 and MODX 3.2.4 on PHP 8.3. The component directories of the repository are mounted in both, so changes to PHP and the built JS show at once. Changes in src/ show after npm run build.
# if your uid/gid are not 1000: export HOST_UID=$(id -u) HOST_GID=$(id -g)
docker compose up -d --build
docker compose logs -f modx2 modx3 # wait for "[fetchit] Manager: …"
# MODX 2: http://localhost:8052/, MODX 3: http://localhost:8053/ (admin / FetchItDev2026)The package is built on MODX 2 and installed on both sites the same way CI does it:
docker compose exec -u www-data -e PKG_DIST=1 modx2 php /extra/_build/build.php
docker compose exec -u www-data modx2 php /extra/_build/ci/install.php /extra/_packages/fetchit-<version>-<release>.transport.zip
docker compose exec -u www-data modx3 php /extra/_build/ci/install.php /extra/_packages/fetchit-<version>-<release>.transport.zipWithout PKG_DIST=1, build.php also installs the package on the site it is built on. To check upgrades from earlier versions there are clean sites without the directories of the repository, see compose.clean.yml.
HTTP checks and browser tests on a local site:
fixtures=$(docker compose exec -T -u www-data modx2 php /extra/_build/ci/fixtures.php)
_build/ci/smoke.sh http://localhost:8052 "$fixtures"
npx playwright install chromium
BASE_URL=http://localhost:8052 FIXTURES="$fixtures" npm run e2e- The snippet (
core/components/fetchit/elements/snippets/) outputs the chunk of the form, gives it thedata-fetchitkey (a hash of the call's properties), its config indata-fetchit-configand the service fields of the protection, stores its properties under that key, and processes a form sent without JavaScript itself. FetchItScriptasks for the script on a page whose forms come later, and leaves the<!--FetchItScript-->marker, which a cached call keeps in the cached page.- The plugin on
OnWebPagePrerenderadds the script and the bootstrap that callsFetchIt.observe()to<head>when either snippet ran in the request or the page has the marker ofFetchItScript, which it takes out. It runs at priority -10, before plugins that minify the page. A fragment without<head>and<body>gets nothing. - The script (
src/, built intoassets/components/fetchit/js/) picks up the forms with their config, also those inserted later, sends the form toaction.phpwith anX-FetchIt-Actionheader, solves the proof of work, gets the captcha's answer, shows the errors and messages, and dispatches the events. action.phpfinds the properties of the form by its key, checks the protection and runs FormIt or your snippet. The answer to a submission with anX-FetchIt-Submissionheader is kept for an hour, so a retry gets the same one; a redirect during processing becomes a successful answer withredirect.FetchItGuardchecks the token, the limit, the trap, the proof of work, the fill time and the captcha (FetchItCaptcha), then firesOnFetchItBeforeProcess.
CI checks every PR: PHP syntax on 7.4 to 8.4, PHPUnit, the linter, the types, the tests, that the committed JS matches src/ and has ES2019 syntax, the workflows and shell scripts, and that the versions agree. Then it builds the package on MODX 2.8.6, installs it on MODX 2.8.6 and 3.2.4, over 1.1.3 and 3.1.4 too, and sends forms over HTTP (with anonymous sessions and without) and from a browser (Playwright): with the protection, a proof of work, Turnstile and the built-in notifications.
The database store is tested on SQLite in the usual PHPUnit run, and in CI on MySQL, MariaDB and SQL Server too. Locally, on the MySQL from compose.yml:
FETCHIT_TEST_DSN='mysql:host=127.0.0.1;port=13352;dbname=modx3' FETCHIT_TEST_USER=root FETCHIT_TEST_PASSWORD=root vendor/bin/phpunit --filter 'DbStoreTest|StoreResolverTest'
To release:
- Raise the version in
_build/config.inc.php(andreleasewhen needed,alpha→plfor example) andcore/components/fetchit/model/fetchit.class.php, and inpackage.jsonandpackage-lock.jsonwithnpm version X.Y.Z --no-git-tag-version. - Add a
## [X.Y.Z] - YYYY-MM-DDsection tocore/components/fetchit/docs/changelog.txt. - Merge it into
masterand push a tag:git tag vX.Y.Z && git push origin vX.Y.Z.
The workflow checks the version, builds the package, runs it on MODX 2 and 3, and only then creates the GitHub release with the package. The release notes come from the feat, fix, perf and refactor commits (conventional commits); when there are none, from the changelog section.
FetchIt 4 is developed in master. The earlier lines are no longer developed: 1.x for MODX 2 (last release 1.1.3) stays in the history of master, 3.x for MODX 3 (last release 3.1.4) in the next branch.
