arout / rhapsody-forms
Forms for Rhapsody: code-defined forms, spam protection, a submissions inbox and email notifications.
Package info
github.com/arout77/Rhapsody-Forms
Type:rhapsody-module
pkg:composer/arout/rhapsody-forms
Requires
- php: >=8.4.1
- ext-mbstring: *
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Define a form in a few lines of PHP, drop it into any page with one Twig tag, and manage what people send you in an admin inbox. Spam protection, email notifications and a developer-friendly event system are built in.
Package: arout/rhapsody-forms | Type: Rhapsody module | License: proprietary
This is the Core tier of the "Rhapsody Core Team" module suite. It is written for Rhapsody developers and agencies building sites for clients.
Contents
- What you get
- Requirements
- Installation
- Quick start: your first form in five minutes
- Defining forms
- Showing a form on a page
- What happens when someone submits
- Spam protection
- Email notifications
- The inbox
- Settings
- Events: reacting to submissions from your own code
- Where your data lives (and privacy)
- Security notes
- Troubleshooting
- Known limitations
- Core and Pro
- Uninstalling
- Changelog
What you get
- Forms defined in code. A plain PHP array describes the fields, rules and settings. Mistakes (a misspelled rule, a duplicate field) are reported the moment the form is registered, not when a visitor hits submit.
- One tag to display a form:
{{ rhapsody_form('contact') }}. - Ten field types: text, email, tel, url, number, textarea, select, radio, checkbox, hidden.
- Validation using the same rule names as the framework (
required,email,min:5,in:a,b, ...). - Five layers of spam protection that need no setup, plus optional reCAPTCHA.
- An admin inbox to read, filter, mark as read, delete and export submissions as CSV.
- Email notifications with the visitor's address as Reply-To, so you answer by simply hitting Reply.
- Events (
FormSubmitting,FormSubmitted) so other modules can veto spam or react to new submissions, such as adding the visitor to a newsletter. - Privacy-minded storage: visitor IP addresses are never stored, only a one-way hash used for rate limiting.
Requirements
| What | Why |
|---|---|
| Rhapsody v2.3.0 or newer | Forms is built and tested against v2.3.0 and declares it as its minimum (rhapsody_core in module.json). |
PHP 8.4.1 or newer with the mbstring extension |
Same as the framework. Names and messages are measured in characters, not bytes. |
MySQL or MariaDB with utf8mb4 |
Submissions are stored as UTF-8 JSON (emoji included). |
Mail configured (MAIL_HOST and friends in .env) |
Only for email notifications. Forms work and save without it. |
| An admin gate (see The inbox) | So only you can open the inbox. |
Core features the module depends on. Rhapsody v2.3.0 and newer provide all of these:
- The
mail.sendmodule permission ($context->mail()). - The
events.dispatchmodule permission. - The built-in
adminroute middleware. - Route middleware that fails closed, meaning a route that names a middleware that isn't registered throws an error instead of silently running unprotected.
Recaptcha::isEnabled().
Safety check built in. When the module starts it looks for the core
adminmiddleware. If it isn't there, the module refuses to register the inbox routes and writes a line to the PHP error log, rather than risk exposing your submissions to the public.
Installation
From your application's root folder (the one containing composer.json):
composer require arout/rhapsody-forms php rhapsody module:install rhapsody-forms
The second command activates the module. Composer having the package is not enough on its own: activation is a separate step, tracked by the framework.
Activation creates one database table, mod_arout_rhapsody_forms_submissions (see Where your data lives), and generates a private signing secret (see Settings).
Quick start: your first form in five minutes
Step 1. Decide who counts as an admin
Open your .env file and add the id of your own user account. If you don't know it, look at the user_id column of your users table.
ADMIN_USER_IDS=1
Several admins? Separate the ids with commas: ADMIN_USER_IDS=1,7. If your application stores admins differently (an is_admin column, a roles table), see Access: who can open the inbox instead.
Step 2. Tell the module where to email submissions
Open storage/modules/arout-rhapsody-forms/settings.json. The file was created when you installed the module and already holds a signing_secret line: leave that line as it is and add a recipient above it:
{
"notify_email": "owner@example.com",
"signing_secret": "...leave the value that is already there..."
}
Skip this step if you only want submissions saved in the inbox. If your site's mail isn't set up yet, see Email notifications.
Step 3. Register a form
Forms are registered in PHP, once per request, before any page is drawn. The simplest place is the bootstrap.php in your application root: the file your own code uses to hook into the framework's startup, not the one inside vendor/.
<?php use Arout\Forms\Form\FormRegistry; FormRegistry::register([ 'slug' => 'contact', 'name' => 'Contact us', 'fields' => [ ['name' => 'name', 'label' => 'Your name', 'rules' => 'required|max:100'], ['name' => 'email', 'label' => 'Email', 'type' => 'email', 'rules' => 'required'], ['name' => 'message', 'label' => 'Message', 'type' => 'textarea', 'rules' => 'required|min:10'], ], ]);
Step 4. Put the form on a page
In any Twig template (a page, a partial, a footer):
<h1>Get in touch</h1> {{ rhapsody_form('contact') }}
There is no |raw filter to add: the tag returns safe HTML.
Step 5. Try it
- Open the page, wait a couple of seconds, fill the form in and send it.
- You should see the success message, and an email should arrive if mail is configured.
- Log in with the admin account and open
/forms/inboxto see the submission.
That's it. The rest of this document explains each piece in detail.
Defining forms
A form is an array with four possible keys: slug, name, fields and settings.
FormRegistry::register([ 'slug' => 'quote-request', // required: used in URLs and the inbox 'name' => 'Quote request', // optional: shown in emails and the inbox 'fields' => [ /* see below */ ], // required: at least one field 'settings' => [ /* see below */ ], // optional ]);
Any other key is rejected with a clear error. That is deliberate: it catches typos such as fieldz or setting.
The form slug
- Lowercase letters, digits and dashes only, starting with a letter or digit, up to 64 characters.
- Good:
contact,quote-request,newsletter-2. Bad:Contact Us,contact_form. - The slug identifies the form everywhere: in
rhapsody_form('...'), in the submit URL, and in the inbox. Changing it later means old submissions keep their old slug.
Form settings
All optional. Anything you leave out gets the default.
| Setting | Default | What it does |
|---|---|---|
notify_email |
(none) | Where this form's submissions are emailed. If empty, the module-wide notify_email setting is used. If that is empty too, no email is sent. |
submit_label |
Send |
Text on the submit button. |
success_message |
Thanks! Your message has been sent. |
Shown after a successful submission. |
redirect |
(none) | A local path such as /thanks. After success the visitor is sent there instead of back to the page they were on. Full URLs (https://...) are refused. |
throttle_per_hour |
5 |
Maximum submissions per visitor, per form, per hour. Whole number; 0 means no limit. |
captcha |
auto |
auto uses reCAPTCHA when your site has it configured. off skips it for this form. |
'settings' => [ 'notify_email' => 'sales@example.com', 'submit_label' => 'Request my quote', 'success_message' => 'Thank you! We will reply within one working day.', 'redirect' => '/thanks', 'throttle_per_hour' => 3, ],
Fields
Each field is an array. Only name is required.
| Key | Required | Meaning |
|---|---|---|
name |
yes | The field's identifier. Lowercase letters, digits and underscores, starting with a letter, up to 64 characters (first_name, phone2). |
type |
no (text) |
One of the field types. |
label |
no | Text shown to the visitor and in emails. Defaults to the name with underscores turned into spaces (first_name becomes "First name"). |
rules |
no | Validation rules joined with |, for example required|max:100. See Rules. |
options |
select and radio only | The choices. See Options. |
placeholder |
no | Faint hint text inside the box. |
help |
no | A small line of help under the field. |
default |
no | A starting value. |
Reserved names. You cannot use _token, _ts, _form, _return, hp_website or g-recaptcha-response as a field name. The module uses them internally.
Only fields you define are saved. If someone adds extra data to the request by hand, it is ignored.
Field types
| Type | Renders as | Validated automatically as |
|---|---|---|
text |
One-line text box | nothing extra |
email |
Email box | a valid email address |
tel |
Phone box | 5 to 30 characters: digits, + - ( ) . and spaces |
url |
Web address box | a valid address starting with http:// or https:// (other schemes such as javascript: or ftp: are refused) |
number |
Number box | a number; min and max compare the value, not the length |
textarea |
Multi-line box | nothing extra |
select |
Drop-down | the value must be one of the options |
radio |
Radio buttons | the value must be one of the options |
checkbox |
Single tick box | stored as yes/no (see below) |
hidden |
Invisible input | nothing extra. Hidden fields are left out of notification emails. |
Checkboxes are single yes/no boxes. Ticked is stored as true, unticked as false. Give a checkbox the required rule to force it to be ticked ("I agree to the terms"). Groups of several tick boxes are not supported in Core.
Options for select and radio
Three ways to write the list. Pick whichever reads best.
// 1. A simple list: each text is both the value and the label 'options' => ['Sales', 'Support', 'Press'], // 2. Value => label (use text keys; PHP turns numeric keys into integers) 'options' => ['sales' => 'Talk to sales', 'support' => 'Get support'], // 3. A list of arrays: the safest choice, and the only one that handles numeric values 'options' => [ ['value' => '0', 'label' => 'Not sure yet'], ['value' => '1', 'label' => 'Under 10 people'], ['value' => '2', 'label' => '10 people or more'], ],
Values must be unique within a field. The submitted value is checked against the list, so a visitor cannot send a choice you did not offer.
Rules
Rules go in the rules string, separated by |. Some take a value after a colon.
| Rule | Passes when | Example |
|---|---|---|
required |
the field is not empty. For a checkbox: it is ticked. | required |
accepted |
the value is 1, on, true or yes |
accepted |
email |
a valid email address | email |
url |
a valid http/https address |
url |
numeric |
a number | numeric |
alpha |
only letters (accented letters count) | alpha |
alphaNum |
only letters and digits | alphaNum |
min:N |
at least N characters. For number fields, or with the numeric rule, the value is at least N. |
min:10 |
max:N |
at most N characters (value for numbers) | max:50 |
in:a,b,c |
the value is one of the listed ones | in:red,green |
notIn:a,b,c |
the value is none of the listed ones | notIn:admin,root |
dateFormat:F |
a real date in format F (PHP date format) | dateFormat:Y-m-d |
Things worth knowing:
- Optional empty fields are skipped. Every rule except
requiredonly runs when the visitor typed something. - A value of
0counts as filled in. (The framework's own validator treats0as empty; this module deliberately does not.) - Length limits always apply. Text fields allow up to 255 characters and textareas up to 5,000 unless you say otherwise with
max:. Nothing over 20,000 characters is ever accepted. Counting is in characters, soéand emoji each count as one. - Whitespace is trimmed, line endings are normalised, and control characters are stripped. Line breaks are only kept in textareas.
- One message per field. If a field breaks several rules, the visitor sees the first problem.
- Mistakes in rules fail early.
'rules' => 'reqiured'throws an error at registration listing the allowed rule names. in:values cannot contain commas. If a choice needs a comma, use aselectorradiofield and list the options instead.
A bigger example
FormRegistry::register([ 'slug' => 'quote-request', 'name' => 'Quote request', 'fields' => [ ['name' => 'company', 'label' => 'Company', 'rules' => 'required|max:120'], ['name' => 'contact', 'label' => 'Your name', 'rules' => 'required|alpha|max:80'], ['name' => 'email', 'label' => 'Work email', 'type' => 'email', 'rules' => 'required'], ['name' => 'phone', 'label' => 'Phone', 'type' => 'tel', 'help' => 'Optional'], ['name' => 'team', 'label' => 'Team size', 'type' => 'radio', 'options' => [['value' => '1', 'label' => '1-10'], ['value' => '2', 'label' => '11-50'], ['value' => '3', 'label' => '51+']], 'rules' => 'required'], ['name' => 'budget', 'label' => 'Budget (USD)', 'type' => 'number', 'rules' => 'min:500|max:1000000'], ['name' => 'website', 'label' => 'Current website', 'type' => 'url', 'placeholder' => 'https://'], ['name' => 'details', 'label' => 'Project details', 'type' => 'textarea', 'rules' => 'required|min:20|max:3000'], ['name' => 'terms', 'label' => 'I agree to be contacted about this request', 'type' => 'checkbox', 'rules' => 'required'], ], 'settings' => [ 'notify_email' => 'sales@example.com', 'submit_label' => 'Request a quote', 'success_message' => 'Thank you! We will reply within one working day.', 'redirect' => '/thanks', ], ]);
Where forms come from
FormRegistry::register() is the Core way. Other packages (the Forms Pro visual builder, for example) can supply forms from elsewhere by implementing Arout\Forms\Form\FormProviderInterface and calling FormRegistry::addProvider(). A form registered directly with register() always wins over one from a provider with the same slug.
Showing a form on a page
{{ rhapsody_form('contact') }}
Place it anywhere in a template. You can put several different forms on one page. (Read the captcha limitation first if you use reCAPTCHA.)
The tag draws the whole form: the fields, the submit button, the hidden spam traps, the security token, and the reCAPTCHA widget if your site has one configured.
What the visitor experiences
-
A normal submit sends the visitor back to the page they were on (or to the form's
redirectpage) with the success message shown, or with each problem field highlighted and everything they typed kept. -
A JavaScript submit (a
fetch()call that sendsAccept: application/json) gets a JSON answer instead of a redirect:{ "ok": true, "message": "Thanks! Your message has been sent." } { "ok": false, "message": "Please fix the highlighted fields.", "errors": { "email": ["Email must be a valid email address."] } }If the form has a
redirectsetting, a successful answer also carries"redirect": "<url>"for your script to follow. The HTTP status is200for success,422for problems the visitor can fix,429when they are being rate limited,404if the form slug does not exist, and500if the server could not save the submission.The easiest way to send a form from JavaScript is to let the browser collect every field, including the hidden security fields (
_token,_ts, the honeypot), and ask for JSON back:const form = document.querySelector('#rforms-contact'); form.addEventListener('submit', async (event) => { event.preventDefault(); const response = await fetch(form.action, { method: 'POST', headers: { 'Accept': 'application/json' }, body: new FormData(form), }); const result = await response.json(); alert(result.message); // or show result.errors next to the fields });
Note that the framework's CSRF check reads
_tokenfrom the request body (not from a header), whichnew FormData(form)includes for you.
If you type a slug that is not registered, the tag prints a small HTML comment instead of a form (<!-- rhapsody_form: no form is registered with the slug "..." -->), so a typo never breaks the page. Look for it with View Source.
Styling
A form looks like the rest of the Rhapsody module screens, on any theme, because it uses the same shared baseline stylesheet and the same .rhapsody-* class contract the module pages use. The first form on a page prints that stylesheet once; later forms on the same page reuse it. It adapts to light and dark backgrounds, takes its text colour and font from the surrounding page, and reads the same optional theme variables:
| Variable | Controls |
|---|---|
--rhapsody-primary |
the button colour and focus rings (defaults to indigo) |
--rhapsody-primary-contrast |
text colour on the button |
--rhapsody-danger, --rhapsody-success |
error and success colours |
--rhapsody-radius |
corner roundness |
--rhapsody-gap |
spacing between fields |
--rhapsody-border, --rhapsody-surface |
input borders and tinted backgrounds |
To make forms match your theme, set these once in the theme's :root, for example --rhapsody-primary: #b8860b;. Every form and every module screen then follows. This is the single biggest step: without it the button uses the default indigo.
Every element also carries a module-specific rforms__* class, so a theme can restyle just the forms without touching other module screens:
| Element | Shared class | Forms hook |
|---|---|---|
the <form> |
rhapsody-form |
rforms |
| one label + input | rhapsody-field (rhapsody-field--error when invalid) |
rforms__field (rforms__field--error) |
| a label | rhapsody-label |
rforms__label |
| text inputs, selects, textareas | rhapsody-input, rhapsody-select, rhapsody-textarea |
rforms__input |
| a radio button or checkbox with its label | (none in the shared contract) | rforms__choice |
| the help line | rhapsody-help |
rforms__help |
| an error message | rhapsody-error |
rforms__error |
| the message banner | rhapsody-alert, rhapsody-alert--success, rhapsody-alert--error |
rforms__alert, rforms__alert--success, rforms__alert--error |
| the submit button | rhapsody-btn |
rforms__submit |
The form sits inside a wrapper <div class="rhapsody-ui rforms-wrap">. The module resets that wrapper so it is just a plain box inside your page (the shared baseline normally makes it a full-width, padded page container).
Light and dark themes. A form takes its text colour from the page around it. Some dark themes colour individual elements and leave their containers at the browser's default black, which would make a form black-on-dark and invisible. To prevent that, the first form on a page also prints a tiny script. For each form it:
- finds the background actually painted behind it (the first mostly opaque background colour on the form's parent elements);
- checks the text against it, and only if the contrast is below the accessibility minimum (4.5:1) puts a readable colour on the form;
- sets
color-scheme(unless the theme already did) so native controls, such as the open drop-down list, match a light or dark page.
A theme that already works is left alone. The check repeats when the page finishes loading and when a dark-mode switch changes a class or data-theme on <html> or <body>.
It can only judge what it can read. If your theme paints its dark look with a gradient or image instead of a background colour, it falls back to the usual "this page is dark" markers (a dark class, data-theme="dark", or color-scheme: dark on <html> or <body>). With none of those it assumes a light page. The simple fix for such a theme is to give the footer or section the form sits in a background-color, or to set color on that container.
If your theme provides its own form styling and you want none of the shared look, switch the built-in styles off with the include_css setting. The classes stay on the elements, so your own CSS still has something to target.
What happens when someone submits
Every submission goes through the same steps, cheapest check first:
| # | Step | If it fails |
|---|---|---|
| 1 | Honeypot. A hidden box real people never see. | A bot filled it in, so the visitor is shown the normal success message, but nothing is saved, emailed or announced. The bot learns nothing. |
| 2 | Time-trap. Every form carries a signed timestamp of when it was drawn. | Tampered or missing: "Something went wrong. Please reload the page and try again." Sent too quickly: "That was quick! Please check your details and send it again." Older than 24 hours: "This form has expired. Please check your details and send it again." |
| 3 | Validation of every field. | "Please fix the highlighted fields." plus a message per field. Everything typed is kept. |
| 4 | Rate limit per visitor, per form. | "You've sent several messages recently. Please try again a little later." |
| 5 | reCAPTCHA, when configured and not turned off for the form. | "Please verify that you are not a robot." |
| 6 | FormSubmitting event. Other code may veto. |
The reason the listener gave, or "Your submission could not be accepted." |
| 7 | Save to the database. | "We couldn't save your message. Please try again." |
| 8 | FormSubmitted event. |
Failures here are logged and never shown to the visitor. |
| 9 | Email notification. | Failures are logged and recorded on the submission. The visitor still sees success. |
Two design choices worth understanding:
- The submission is saved before anything that can fail slowly. If the mail server is down or another module's listener crashes, the message is still safely in your inbox.
- Validation runs before the captcha. A typo in an email address should not cost the visitor a captcha solve.
Spam protection
You get four layers with no configuration, and a fifth if you set up reCAPTCHA.
- Honeypot field. Catches the many bots that fill in every input they find.
- Signed time-trap. Bots usually submit within milliseconds. The timestamp is signed with your site's private secret, so it cannot be forged or reused on another form. The minimum wait defaults to 2 seconds (
min_submit_seconds). - Rate limit. By default one visitor can save at most 5 submissions per form per hour. It is counted from saved submissions using a hash of the visitor's IP address (never the address itself). Change it per form with
throttle_per_hour. - Veto event. Add your own filter (keywords, link counts, an Akismet check) by listening to
FormSubmitting. See Events. - reCAPTCHA (optional). Add your Google reCAPTCHA v2 keys to
.env:RECAPTCHA_SITE_KEY=your-site-key RECAPTCHA_SECRET_KEY=your-secret-key
Forms then show the "I'm not a robot" box automatically. With both keys missing, forms skip the captcha instead of rejecting everyone. To skip it for one form even when keys exist, set'captcha' => 'off'in that form's settings.
If your site is behind a proxy or CDN
This is the one thing to check before you go live.
The module normally identifies a visitor by the connection's address. Behind a proxy or CDN (Cloudflare, a load balancer), every visitor appears to come from the proxy's address, so the rate limit would apply to all your visitors combined.
Fix it by naming the header your proxy sets in the trusted_proxy_header setting:
| Behind | Set trusted_proxy_header to |
|---|---|
| Cloudflare | CF-Connecting-IP |
| A typical load balancer or reverse proxy | X-Forwarded-For |
| Nothing (a direct connection) | leave it empty |
Only set this if you really are behind a proxy that you control. Anyone can send a fake
X-Forwarded-Forheader. If you name a header your server does not overwrite, a bot can pretend to be a different visitor on every request and walk straight past the rate limit.
Email notifications
When a submission is saved, the module emails the recipient set in the form's notify_email (or the module-wide notify_email setting if the form has none).
- Subject:
New submission: <form name> - Body: a table of every field and the visitor's answer (checkboxes show Yes or No; hidden fields are left out), plus the submission number. A plain-text version is included. Everything the visitor typed is HTML-escaped.
- From: always your site's own address from its mail configuration. The module cannot change it.
- Reply-To: the visitor's address, taken from the first
email-type field on the form (when it is a valid address). Hit Reply to answer them directly.
Mail is best-effort
The submission is saved first, then the email is attempted. The inbox records the outcome for each submission:
| Status | Meaning |
|---|---|
sent |
The mail server accepted the message. |
failed |
Something went wrong (server down, bad credentials). The reason is in the PHP error log. The submission itself is safe. |
skipped |
No email was attempted: either the site has no mail configured (MAIL_HOST is empty) or there is no recipient for this form. |
To set up mail, fill in the MAIL_* settings in your .env. The module only uses what the framework is already configured with.
A safety limit
A module may send at most 20 emails while handling a single request. A form only ever sends one, so you will never notice it.
The inbox
The inbox is the admin area for everything people have sent you.
| Page | Address | What it does |
|---|---|---|
| Inbox | /forms/inbox |
Lists submissions, newest first, 25 per page. Filter by form or by new/read. |
| One submission | /forms/inbox/{id} |
Shows every answer, the time received (UTC), and the email status. Opening a submission marks it as read. |
| Mark as unread, delete | buttons on a submission's page | Deleting removes the submission for good (you are asked to confirm). |
| CSV export | /forms/export |
Downloads submissions as a spreadsheet file. Add ?form=contact to export one form only. |
All of these pages are guarded by the admin gate. If you have a subdirectory install (for example /marketplace/), the addresses start with your base path.
Access: who can open the inbox
Every inbox page uses the framework's built-in admin middleware. It decides who is an admin in this order:
-
Your application's own
adminmiddleware, if you registered one in your middleware map. It is used instead of the built-in one. -
A class you write that implements
AdminGateInterfaceand bind in the container. Use this when "admin" lives in your database:<?php namespace App\Gates; use Rhapsody\Core\Contracts\AdminGateInterface; use Rhapsody\Core\Database; // Example rule: admins are users whose is_admin column is 1. class IsAdminColumnGate implements AdminGateInterface { public function __construct(private readonly Database $db) {} public function isAdmin(string $userId): bool { $stmt = $this->db->getConnection()->prepare( 'SELECT is_admin FROM users WHERE user_id = :id LIMIT 1' ); $stmt->execute(['id' => $userId]); return (bool) $stmt->fetchColumn(); } }
-
ADMIN_USER_IDSin.env, a comma-separated list of user ids, used when nothing else is configured. No code needed.
Visitors who are not logged in are redirected to /login. Logged-in users who are not admins get a 403. If nothing is configured, nobody is an admin, so a fresh install is locked, never open.
CSV export and spreadsheets
Which columns you get depends on how you export:
- One form (
?form=contact, or pick a form in the inbox first): ID, form, received (UTC), status, then one column per field (using the field labels), then the email status. Select and radio answers show their option label. - All forms (no
formfilter): ID, form, received (UTC), status, email status, and a single Answers (JSON) column. Different forms have different fields, so one table cannot give each its own column.
Other things to know:
- At most 25,000 submissions per file, oldest first. The finished file is built in memory, so there is a ceiling. For a bigger archive, export one form at a time and delete what you have saved.
- Accents and emoji open correctly in Excel, because the file starts with a UTF-8 byte-order mark.
- Formulas are neutralised. Cells that begin with
=,+,-or@(other than plain numbers) are prefixed with a single quote, so a malicious visitor cannot smuggle a spreadsheet formula into your file.
Settings
Module-wide settings live in a small JSON file:
storage/modules/arout-rhapsody-forms/settings.json
(The folder is named after the module's slug.) Any setting you leave out uses its default.
| Setting | Default | What it does |
|---|---|---|
notify_email |
(empty) | Default recipient for notifications when a form does not set its own. Empty means no email is sent. |
min_submit_seconds |
2 |
How long a form must have been open before a submission is accepted. Bots submit instantly; people do not. |
trusted_proxy_header |
(empty) | The header your proxy or CDN uses to pass the visitor's address. Leave empty unless you are behind a proxy you control. See If your site is behind a proxy or CDN. |
retention_days |
0 |
Delete submissions older than this many days. 0 keeps everything until you delete it. |
include_css |
true |
Print the shared baseline stylesheet, the small wrapper reset and the contrast script with the forms. Set false if your theme styles the rhapsody-* / rforms__* classes itself. |
delete_data_on_uninstall |
false |
If true when you uninstall the module, the submissions table is dropped as well. Off by default, so an uninstall never destroys your data. See Uninstalling. |
Example (keep your existing signing_secret line in the real file):
{
"notify_email": "owner@example.com",
"min_submit_seconds": 3,
"trusted_proxy_header": "CF-Connecting-IP",
"retention_days": 365,
"include_css": true,
"delete_data_on_uninstall": false
}
The signing secret. When you install the module (or on the first request after, if that was missed) it generates a long random value and stores it in the same file as signing_secret. It signs the time-trap tokens and hashes visitor IP addresses. Don't share the file's contents, and don't commit it to version control. If you delete the entry, a new one is generated; the only effect is that forms already open in someone's browser will ask them to reload once, and the rate-limit history starts fresh.
Retention without a scheduler. There is no cron job. When retention_days is set, a small batch of expired submissions is cleared now and then as a side effect of new submissions (about one in every fifty). A very quiet site may keep expired items a little longer than the setting says.
Events: reacting to submissions from your own code
The module announces two events. Use them to build spam filters, newsletters, CRM syncs and webhooks without touching the module itself. Both live in the Arout\Forms\Events namespace and are part of the module's public API, so their properties will not change within a major version.
FormSubmitting (before saving, can be vetoed)
Fired after validation and the spam checks, before the submission is saved.
| Property or method | Meaning |
|---|---|
$event->formSlug |
Which form, for example contact. |
$event->data |
The cleaned values, keyed by field name. Checkboxes are true/false. |
$event->meta['ip'] |
The visitor's IP address, so filters such as Akismet can work. It is not stored. |
$event->meta['user_agent'] |
The visitor's browser string. |
$event->reject('Reason') |
Refuse the submission. The reason is shown to the visitor, so keep it polite. |
$event->isRejected(), $event->reason() |
Read back the outcome. |
If a listener rejects, nothing is saved or emailed. All other listeners still run. A listener that throws an error counts as "no objection" and the error is logged, so a broken filter can never block real visitors.
FormSubmitted (after saving, for follow-up work)
Fired after the submission is saved. The data is already safe, so a failing listener cannot lose it.
| Property | Meaning |
|---|---|
$event->submissionId |
The new row's id. |
$event->formSlug, $event->formName |
Which form. |
$event->data |
The saved values, keyed by field name. |
Listening from another module
Two things are needed: permission in your module's module.json, and a listener registered in your provider.
1. module.json. List the event with its full class name. Short names do not work.
"permissions": { "events.listen": { "listen": [ "Arout\\Forms\\Events\\FormSubmitting", "Arout\\Forms\\Events\\FormSubmitted" ] } }
2. Your provider's boot():
use Arout\Forms\Events\FormSubmitted; use Arout\Forms\Events\FormSubmitting; // Inside boot(), where you have the ModuleContext as $context: // A tiny spam filter: no links in the message. $context->events()->listen(FormSubmitting::class, function (FormSubmitting $event) { if ($event->formSlug === 'contact' && preg_match('#https?://#i', (string) ($event->data['message'] ?? ''))) { $event->reject('Please remove links from your message.'); } }); // Follow-up work: add newsletter ticks to a mailing list. $context->events()->listen(FormSubmitted::class, function (FormSubmitted $event) { if (($event->data['newsletter'] ?? false) === true) { // call your mailing-list service here with $event->data['email'] } });
A few rules of the road:
- Listeners run inside the visitor's request. A slow listener (an outbound web request, for instance) makes the visitor wait. Keep them quick and set short timeouts.
- Do your own checks. Treat
$event->datalike any user input before putting it anywhere sensitive. - Use the right event. Veto in
FormSubmitting. Do follow-up work inFormSubmitted, which only fires for submissions that were actually saved.
Where your data lives (and privacy)
The table
The module creates one table, mod_arout_rhapsody_forms_submissions. The prefix is derived from the package name; it is the only table the module may write to.
| Column | Contents |
|---|---|
id |
Submission number. |
form_slug |
Which form. |
data |
The answers as JSON (UTF-8, emoji supported). |
status |
new or read. |
notify_status |
pending, sent, failed or skipped. |
ip_hash |
A 64-character one-way hash of the visitor's IP address. Used only for the rate limit. |
user_agent |
The visitor's browser string, trimmed to 255 characters. |
created_at |
When it arrived, in UTC. |
What is and is not stored
- IP addresses are never stored. Only an irreversible hash, made with your site's private secret, so it cannot be turned back into an address or matched against another site's data.
- The submission's answers and the browser string are stored.
- The IP address and browser string are shown briefly to
FormSubmittinglisteners (so spam filters can work) but are not saved by the module.
Privacy checklist for client sites
- Tell visitors what you do with their messages, in your privacy policy.
- Set
retention_daysif you do not need to keep submissions forever. - To honour a deletion request, find the submission in the inbox and delete it.
Uninstalling keeps your data
Deactivating the module does not delete submissions. They are your site's data, and an accidental uninstall should never wipe them. See Uninstalling for how to remove them deliberately.
Security notes
For the developers and reviewers who want to know what the module does on your behalf.
- CSRF protection comes from the framework. Every form carries the framework's token, and the submit route is checked by the same middleware as the rest of your site.
- Output is escaped. The form, the inbox and notification emails escape everything a visitor typed.
urlfields accept onlyhttp/https. A storedjavascript:link shown in an admin screen would be a script-injection route.- Redirects are local-only. A form's
redirectsetting, and the "return to the page you were on" path, must be paths on your own site. Anything that looks like//elsewhere.comor contains a backslash is refused, so the form cannot become an open redirect. - Email headers cannot be injected. Recipient and Reply-To addresses must be single valid addresses (line breaks are rejected) and subject lines have line breaks removed.
- Spreadsheet formula injection is blocked in CSV exports (see above).
- The inbox fails closed. If the admin middleware is not available, the inbox routes are not registered at all.
- Fake proxy headers are ignored unless you explicitly trust a header.
- Least privilege. The module declares exactly the permissions it needs: its own routes under
/forms, its own table, sending mail, dispatching its two events, one Twig extension (therhapsody_formtag) and its settings file.
Troubleshooting
The form does not appear.
Check that the slug in rhapsody_form('...') matches a registered form exactly, and that FormRegistry::register() runs on every request before the page renders (for example in your application's bootstrap.php). A mistake in a definition throws an error with a message naming the form and field. If the page renders but the form is missing, View Source and search for rhapsody_form: the tag leaves a comment saying either that the slug is not registered or that the signing secret is unavailable (check that storage/modules/arout-rhapsody-forms/ is writable by the web server).
"Something went wrong. Please reload the page and try again." The form's time-trap token was missing or did not verify. Usual causes: the page is served from a full-page cache (the tokens are per visitor, so don't cache pages that contain forms), the signing secret was changed or deleted since the page loaded, or a script is posting to the form directly.
"This form has expired." The page had been open for more than 24 hours. Sending again works. If this happens to everyone, the page is being cached; exclude it from your cache.
"That was quick!" for a real person.
They submitted in less than min_submit_seconds (default 2), usually via browser autofill. Sending again works. Lower the setting to 1 if it troubles your audience.
Everyone suddenly gets "You've sent several messages recently".
You are probably behind a proxy or CDN, so every visitor looks like the same address. Set trusted_proxy_header. See If your site is behind a proxy or CDN.
Submissions save but no email arrives. Open the submission in the inbox and read its email status:
skipped: noMAIL_HOSTconfigured, or no recipient. Setnotify_emailand check your.envmail settings.failed: the mail server refused it. Look for a line startingForms: notification for submissionin the PHP error log.sentbut still missing: check spam folders. Make sure your sending domain has SPF and DKIM records; the From address is always your site's own.
The labels or inputs are invisible (usually a dark theme).
The form takes its text colour from the page, and the contrast script could not tell the page is dark. View Source and check that <script id="rforms-guard"> is present (it is not when include_css is false). Then give the section the form sits in a background-color, or add a dark class or data-theme="dark" to <html>, or set color on the container. Setting --rhapsody-primary alone changes only the button.
The captcha box does not appear.
Both RECAPTCHA_SITE_KEY and RECAPTCHA_SECRET_KEY must be set in .env. With either missing the module treats reCAPTCHA as not configured.
"We couldn't verify the captcha."
Your server could not reach Google within 5 seconds. Check your firewall and that PHP's allow_url_fopen setting is on; the framework's captcha check relies on it.
403 when opening the inbox. You are logged in but not an admin. See Access: who can open the inbox.
An error that mentions a middleware "which is not registered".
Your core is not new enough to include the built-in admin middleware, or your application overrides the middleware map without an admin entry. Update core, or add an admin entry. (The module's startup check normally prevents the inbox routes from being registered in this situation.)
Inbox pages are not found.
The module may not be activated. Run php rhapsody list to see the module commands your version provides, then php rhapsody module:install rhapsody-forms. Check the PHP error log for a line saying the inbox was not registered because the admin middleware is missing.
Known limitations
These are deliberate boundaries of the Core tier, not bugs.
- Forms are defined in code. There is no visual form builder in Core (see Core and Pro).
- One reCAPTCHA form per page. Each form that shows the captcha loads Google's script tag, so two captcha-protected forms on one page can conflict. Use
'captcha' => 'off'on all but one, or use one form per page. - The rate limit counts saved submissions only. Visitors who keep sending invalid forms are not counted. Site-wide flood protection is the job of the framework's DDoS middleware.
- Single tick boxes only, not groups of checkboxes, and no multi-select lists.
- No file uploads, multi-step forms or conditional fields in Core.
- Retention clean-up is opportunistic (see Settings): it depends on new submissions arriving.
- Listeners run synchronously, so slow listeners slow the visitor's request.
- CSV export is capped at 25,000 rows per file (see CSV export and spreadsheets).
- The dark-theme contrast check works from background colours and common dark-mode markers. A theme that paints its dark look only with a gradient or image, and has no marker, is treated as light until you give the area a
background-color(see Styling). - Times are stored and shown in UTC.
- A failing veto listener fails open: if a spam filter crashes, the submission goes through rather than blocking real visitors.
Core and Pro
Core (arout/rhapsody-forms) |
Pro (planned) | |
|---|---|---|
Forms defined in PHP, rhapsody_form() tag |
yes | yes |
| Ten field types, validation rules | yes | yes |
| Honeypot, time-trap, rate limit, reCAPTCHA | yes | yes |
| Admin inbox, CSV export | yes | yes |
| Email notifications with Reply-To | yes | yes |
FormSubmitting and FormSubmitted events |
yes | yes |
| Visual drag-and-drop form builder | yes | |
| File uploads | yes | |
| Auto-reply email to the visitor | yes | |
| Conditional fields and multi-step forms | yes | |
| Webhooks and integrations | yes | |
| Submission notes and statuses | yes | |
| Retention policies per form | yes |
Pro will be a separate package that depends on Core and builds on the events and FormProviderInterface described above.
Uninstalling
- Deactivate the module with the framework's module command, the counterpart of
module:install(runphp rhapsody listto see the exact name in your version, for examplemodule:uninstall):php rhapsody module:uninstall rhapsody-forms
This stops the module from running. Your submissions stay in the database, unless you set"delete_data_on_uninstall": truein the settings file before running this command, in which case the table is dropped too. - Remove the package if you no longer want the code:
composer remove arout/rhapsody-forms
- Remove the
rhapsody_form()tags from your templates and theFormRegistry::register()calls from yourbootstrap.php. - Delete the data, if you did not use
delete_data_on_uninstalland want it gone for good. This cannot be undone, so export a CSV first. In your database tool run:DROP TABLE mod_arout_rhapsody_forms_submissions;
and deletestorage/modules/arout-rhapsody-forms/.
Changelog
1.0.0 — Initial release: code-defined forms, ten field types, validation, spam protection (honeypot, signed time-trap, rate limit, optional reCAPTCHA), admin inbox with CSV export, email notifications, and the FormSubmitting and FormSubmitted events.