webx-ui / module-inbox
Forms and submissions for the WebX UI admin panel: forms built in the panel, a public intake with antispam, notifications by mail and a list of submissions with statuses, an assignee and a log.
Requires
- php: ^8.4
- illuminate/contracts: ^13.0
- illuminate/database: ^13.0
- illuminate/http: ^13.0
- illuminate/mail: ^13.0
- illuminate/routing: ^13.0
- illuminate/support: ^13.0
- illuminate/validation: ^13.0
- illuminate/view: ^13.0
- webx-ui/localization: ^0.79.0
- webx-ui/mcp: ^0.79.0
- webx-ui/module-admin: ^0.79.0
- webx-ui/module-auth: ^0.79.0
- webx-ui/widgets: ^0.79.0
Requires (Dev)
- orchestra/testbench: ^11.0
- phpunit/phpunit: ^12.0 || ^13.0
- webx-ui/module-audit: ^0.79.0
- webx-ui/themes: ^0.79.0
Suggests
- webx-ui/module-audit: The site audit reports switched-on forms that would notify nobody, and notifications that failed or have waited in the queue for long.
- webx-ui/module-notifications: Notify through channels too: Telegram, e-mail by a template of its own, any provider a package adds — chosen per form beside the letter.
- webx-ui/themes: With a theme, the form gets its base look on site tokens (resources/theme/inbox.css).
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v0.79.0
- v0.78.0
- v0.77.0
- v0.76.0
- v0.75.0
- v0.74.0
- v0.73.0
- v0.72.0
- v0.71.0
- v0.70.0
- v0.69.0
- v0.68.0
- v0.67.0
- v0.66.0
- v0.65.0
- v0.64.0
- v0.63.0
- v0.62.1
- v0.62.0
- v0.61.1
- v0.61.0
- v0.60.0
- v0.59.0
- v0.58.0
- v0.57.0
- v0.56.0
- v0.55.0
- v0.54.0
- v0.53.1
- v0.53.0
- v0.52.0
- v0.51.0
- v0.50.0
- v0.49.2
- v0.49.1
- v0.49.0
- v0.48.0
- v0.47.0
- v0.46.0
- v0.45.0
- v0.44.1
- v0.44.0
- v0.43.0
- v0.42.3
- v0.42.2
- v0.42.1
- v0.42.0
- v0.41.0
- v0.40.1
- v0.40.0
- v0.39.0
- v0.38.0
- v0.37.0
- v0.36.0
- v0.35.1
- v0.35.0
- v0.34.0
- v0.33.0
- v0.32.1
- v0.32.0
- v0.31.0
- v0.30.0
- v0.29.1
- v0.29.0
- v0.28.0
- v0.27.2
- v0.27.1
- v0.27.0
- v0.26.1
- v0.26.0
- v0.25.1
- v0.25.0
- v0.24.1
- v0.23.0
- v0.22.1
- v0.22.0
- dev-next
This package is auto-updated.
Last update: 2026-10-18 16:34:08 UTC
README
Forms and submissions as a section of the WebX UI admin panel. An administrator builds a form in the panel by dragging fields into it, the site prints it, a visitor sends it, the people named on the form get a letter, and the panel keeps the submissions with a status, an assignee and a log.
Not a CRM: no deals, no amounts, no pipeline. "Somebody wrote in — somebody is dealing with it — it is closed."
Requirements
- PHP 8.4+, Laravel 13
webx-ui/module-admin,webx-ui/module-auth,webx-ui/localization,webx-ui/mcp
Install
composer require webx-ui/module-inbox php artisan migrate
The migrations create the inbox_* tables and seed five statuses — New, In progress, Done,
Rejected, Spam — in every language the package ships words in. Permissions: inbox.view,
inbox.update, inbox.manage.
A field is a row, not a schema node
This is the one place the module parts company with module-settings and module-blocks, and
it is deliberate. A field has an identity that an answer points at, a position somebody drags,
and a flag saying whether it becomes a column in the list of submissions. A JSON array gives
none of those cheaply: building a column would mean walking inside a JSON document, and keeping
an answer tied to its field would mean inventing a stability its keys do not have.
Every field has a machine name, so the HTML says fields[email] rather than field_17. A site
fills its hidden fields by that name, an export is headed by it, and renaming the label breaks
neither. A field with no name of its own answers to f{id}.
Answers keep a copy of the question
Beside every answer sit the field's name, label and type as they were when it arrived:
$submission->value('email')->label; // "Your e-mail", even after the field was renamed
A submission from a year ago reads with the words it was given. That is versioning of the schema for the price of three columns — and it is why a field is deleted softly: it leaves the form and leaves the new submissions, and stays in the ones it is already part of.
For the same reason a form that has taken submissions can be switched off but not deleted. Both the model and a restricted foreign key say so.
The public door
POST {webx-inbox.path}/{slug} default: /webx/forms/{slug}
The route builds its middleware by hand: it is the web group minus VerifyCsrfToken. A page
cached whole carries the token that was minted when the cache was written, and a stale token is
a 419 that happens only on a live site and only after a while — the worst possible shape for a
bug in the one part of a site that customers use. The session stays, because a form without
JavaScript reports its errors through it.
Validation comes from the fields, and errors come back under the names the inputs have:
{ "message": "…", "errors": { "fields.email": ["The e-mail must be a valid address."] } }
The answer is the widgets' FormResponse, the one every form of the site gets: a request that
asks for JSON gets { ok, heading, message, redirect, html } — the thank-you, or the form's
address when it has one; anything else a redirect, there or back with the thank-you in the
session. A refusal is Laravel's own 422, or the errors in the session.
The same thing sent twice
Answers are hashed, and the same hash from the same form inside fifteen minutes updates the submission it matches instead of making a second one. Outside the window it is a new submission — without that, the same enquiry sent again next month would overwrite the first one and take its date with it.
The form on the site
<x-webx-inbox::form slug="contact" /> <x-webx-inbox::form :form="$form" class="my-form" :values="['product' => $product->name]" />
The tag prints the whole thing on the form engine of webx-ui/widgets (guide «Forms»): an
<x-webx-form :csrf="false"> with an <x-webx-field> for every field of the panel — the eleven
types are the widgets' own —, the honeypot, the hidden timestamp, the captcha if the form wants
one, and the button. A slug nobody has a form for — or one that is switched off — prints nothing,
because a page that says "form not found" to a customer is worse than a page with one section
missing.
:values fills the hidden fields by their machine name: which page, which product, which
campaign. That is what a machine name is for.
What the engine does for it, it does for every form of the site: the intake's own rules
(Submissions\Rules) are handed to the form through FormRules, so the browser checks what the
intake will, in the validator's words and the page's language; the form is sent in place, a 422
lands under its fields, the thank-you in a live region — in a dialog in place of the fields. Without
JavaScript the form posts, the intake answers with a redirect, and the page comes back with the
errors under their fields or the thank-you under them — through the session, which is why the
intake keeps one.
The engine's scripts (form.js, field.js, and files.js/mask.js when a field needs them)
come with the page's finish: @webxTheme links them on a site with a theme. A site without a
theme writes @webxWidgets before </body> in its layout and runs
php artisan webx:theme:sync once (after that, composer update does; webx:setup --no-theme
writes both) — otherwise every form still works, but reloads the page to answer and is checked
by the server only, and the audit's widgets.form_script says so.
The inbox's own script, {path}/inbox.js, is only the captcha's half of sending: it runs an
invisible captcha or reCAPTCHA v3 on webx:form:submit, holding the form busy with the engine's
detail.wait() from the press to the answer, and resets every captcha after webx:form:success
and webx:form:error. A form without a captcha does not link it.
A page may carry several forms — a request in the body, a subscription in the footer, the same
form in a dialog. Each has its own id (webx-inbox-<slug>, then -2…), which comes back as
_webx_form: the errors, the old input and the thank-you belong to the form that was sent, and
the others stay as the page printed them. For a script of the site, the <form> carries
data-webx-form="<slug>":
document.addEventListener('webx:form:success', (event) => { if (event.target.dataset.webxForm === 'contact') window.dataLayer?.push({ event: 'request' }) })
Where the form stands
<x-webx-inbox::form slug="subscribe" placement="footer" />
One form of subscription in the footer and in every article is one form, and placement is
what tells the two apart. The <form> gets webx-inbox-form--footer beside webx-inbox-form, so
the site styles each by CSS alone; the view of the form gets $placement; and a hidden
webx_placement makes the submission remember it — the panel shows "Placement: footer" in the
card and offers a filter once a form has come in from more than one place. The value is a name
([a-z][a-z0-9-]*, up to 32 characters); anything else is dropped with a warning in the log
rather than an exception, because the form on the page matters more than a typo in one of its
attributes. none is reserved: it is what the filter says for "the page did not say".
Making it yours
The fields, the messages and the button are the widgets' webx-field-* and webx-form-*, the
same classes as every form of the site. On a site with a theme (webx-ui/themes) the package
brings only its layout, resources/theme/inbox.css: fields two to a row where the panel marked
them half width (webx-inbox-form__field--half) and the form is at least 32rem wide. The theme
restyles all of it without publishing a view. A site without a theme gets no stylesheet of ours —
only the scripts, through @webxWidgets (above) — and styles the same classes in its own CSS.
Restyling is the cheap door; a different frame is the expensive one. After publishing, the views belong to the site and no longer get the package's fixes:
php artisan vendor:publish --tag=webx-inbox-views
resources/views/vendor/webx-inbox/form.blade.php the frame, on <x-webx-form>
resources/views/vendor/webx-inbox/form/captcha.blade.php the captcha and its error
resources/views/vendor/webx-inbox/form/honeypot.blade.php the field for robots
Keep <x-webx-form> and the fields in it, the hidden webx_form, webx_locale and webx_page,
the honeypot, the timestamp and the captcha's place (data-webx-field="captcha"). One control of
one type for every form of the site is the widgets' view
webx-widgets::components.field.<type>, not the inbox's.
Before W6.4 the form had markup and classes of its own (wx-form*) and inbox.js sent it. A
form.blade.php the site published then — any view of the form without <x-webx-form> in it —
keeps working for an alias period: it is handed the variables it was handed, its partials
(field, fields/<type>, message, captcha, honeypot) stay in the package, and its script is
served as {path}/inbox-legacy.js. The audit's inbox.legacy_views names it until it is moved;
the guide «Inbox» has the table from wx-form* to the new classes.
To bundle the captcha script instead of linking ours:
php artisan vendor:publish --tag=webx-inbox-assets # resources/js/vendor/webx-inbox/inbox.js
and set webx-inbox.script to false.
The captcha block
A form says which provider it wants; the site key, the secret and how the captcha runs are the
site's, in webx-inbox.captcha.<provider>:
| Setting | Values | What the visitor sees |
|---|---|---|
recaptcha.type WEBX_INBOX_RECAPTCHA_TYPE |
checkbox (default), invisible, v3 |
the "I'm not a robot" box; nothing (a challenge if needed); nothing |
recaptcha.min_score |
0.5 | v3 only: below it the submission is a robot |
turnstile.mode WEBX_INBOX_TURNSTILE_MODE |
managed (default), invisible |
the widget on load; nothing unless Cloudflare asks |
The type has to match the keys: a reCAPTCHA key drawn as another kind is the widget's "Invalid
key type". A checkbox is drawn by the provider's own script, which the block loads once per
page. An invisible one is drawn by inbox.js on the first submit (webx:form:submit), per form, and run
then, so the token is fresh; a Turnstile widget set to Invisible in Cloudflare shows nothing at
all. v3 is loaded with ?render=<key> and asked for the action webx_form_<slug>. Invisible and
v3 need inbox.js: without JavaScript they are refused with a sentence that says so. While the
captcha runs the form is busy, as while it is sent, and another press does nothing; one that
fails, times out or expires lets the form go with its sentence, and one that never answers — a
reCAPTCHA challenge the visitor closed — does so after two minutes.
A token is good once, so the script resets the widget after every answer — otherwise a visitor who corrects one typo is refused by a captcha they already passed, which reads as a form that simply does not work. A provider that cannot vouch for the browser is told to the visitor at once ("reload the page or try another browser") instead of a post that would be refused anyway.
Antispam
Five layers, in the order they run:
| Layer | What | Refusal |
|---|---|---|
| Honeypot | a field a person never sees | answered "thank you", dropped |
| Timestamp | how fast the form came back, encrypted | 422 |
| Origin / Referer | posted from this site's own pages | 422 |
| Captcha | reCAPTCHA or Turnstile, only if the form asks | 422 |
| Rate limit | submissions a minute from one address to one form | 429 |
The honeypot is answered as a success on purpose: telling a robot which trick was seen is what makes the next attempt harder to catch.
The timestamp has a trap in it. On a page cached whole the mark belongs to the moment the cache was written, not to the moment somebody opened the page, so it reads as hours old for every visitor. A mark older than a day is therefore not judged at all, and the honeypot and the rate limit are the layers that carry the weight.
The captcha is off unless a form asks for it, and its keys are the site's rather than the form's — one pair for every form. A form that asks for a captcha the site has no key for refuses submissions and says so in the log: a rubber stamp would be worse than no captcha, because nobody would know.
Every refusal is one info line in the log with the layer that made it — honeypot, too_fast,
origin, captcha_missing, captcha_failed, captcha_unconfigured — and, for a captcha,
what the provider said (error-codes, a v3 score and action), never the token or the
secret. The visitor hears one sentence for all of them except the captcha they can answer: a
missing or failed one is filed under captcha and shown beside the widget. Refusals are also
counted per form and day in the cache, for the audit below.
With webx-ui/module-audit installed, three checks look at the captcha: inbox.captcha_keys
(error) — a switched-on form asks for a captcha whose key or secret the .env lacks;
inbox.captcha_unused (notice) — the site has keys and a switched-on form uses none;
inbox.spam_without_captcha (warning) — a form without a captcha had at least
thresholds.inbox_spam_min (3) submissions in a spam status or refusals in the last
thresholds.inbox_spam_days (30). There is no check for a form without a captcha as such: the
free layers are the default, and most sites never need more.
Attachments
Files go on the module's own disk — webx-inbox.disk, local by default — under
inbox/{form}/{submission}/{uuid}.{ext}, never into the media library: those are files editors
chose and these are files strangers sent.
Nothing links to them. The panel reads the bytes and serves them itself:
GET {webx-admin.api_path}/inbox/submissions/{submission}/files/{file} inbox.view
A signed address to a bucket would be one somebody forwards in a chat, and it would keep working for its own lifetime rather than for as long as the person has the permission.
Notifications
Recipients are named in the form's options — administrators by account, or plain addresses. Each is written to separately, because each reads in their own language: an administrator in the language they keep the panel in, a typed-in address in the language the submission arrived in.
The mailable is ShouldQueue, so a site with a queue gets one and a site on sync sends inline.
Reply-To is the value of whichever field the form calls its e-mail field — never From, which
is how a domain loses its reputation.
The submission is written before anything is sent. A mail server that is down costs a
notification, recorded on the submission as notify_error, and not an enquiry.
Queued is not sent. On a site with a queue, sending only pushes a job; the SMTP server is
asked later by a worker. So the submission says where its letters are — notification.state in
the panel's API and in MCP:
| state | means |
|---|---|
none |
nobody to write to |
queued |
handed to the queue, not sent yet (notify_queued_at — since when) |
delivered |
every letter left (notified_at) |
failed |
a letter could not be sent; notify_error says why, per recipient in the list |
A worker reports each letter as it settles (SubmissionReceived::send() / failed() →
Mail\Delivery); the log gets notify_queued, then notified with the count, and
notify_failed with the address. On sync nothing changed: the outcome is written at once.
A letter stuck as queued means no worker runs; one that failed after a settings change often
means a worker still holds the old ones — php artisan queue:restart. Then send it again: the
panel's «Send the notification again», POST /api/cms/inbox/submissions/{id}/notify
(inbox.update), or the MCP tool inbox_notify (with dry_run). With webx-ui/module-audit
installed, the check inbox.notification lists letters that failed in the last
thresholds.inbox_failed_days (30) days or have been queued for over
thresholds.inbox_queued_minutes (30).
The letter stands in the frame of the site's theme — mail.layout of
webx-ui/theme-default or of the site's own theme:
the site name above, the letter on a sheet, a line below — and every colour in it is a token's
value from theme_token(), written into style="…" on the server. A mail client reads no CSS
variable, so this is how the theme's preset and accent reach the letter. On a
site without a theme the letter has a frame of its own and theme-default's colours
(WebxUi\Inbox\Mail\Look).
A theme rewrites the letter in views/vendor/webx-inbox/mail/submission.blade.php; a site
without one publishes the views:
php artisan vendor:publish --tag=webx-inbox-views
Take colours from $look('color-…') (or theme_token()), never var( or a literal:
php artisan webx:theme:check reads the letters and names both. A preview of the letter for a
form, with answers nobody sent and nothing saved — what the kitchen sink of theme-default shows:
SubmissionReceived::sample($form, ['name' => 'Ada', 'message' => 'Hello'])->render();
Channels
With webx-ui/module-notifications installed — a suggestion, not a
requirement — a form also notifies through the channels chosen under «Notify through»
(options.notify_channels): Telegram, another letter with its own template, any provider a
package adds. The letter above goes as before, beside them. The inbox declares the event
inbox.submission ({{form}}, {{fields}}, {{field.<name>}}, the list {{#answers}},
{{submission_url}}, the addressee {{reply_to}} and more), sends a submission from the site's
form to each chosen channel (not one sent again inside the duplicate window, not one typed in by
hand), and shows what came of it on the submission's card and in inbox_get (channels). The
guide «Inbox» has the whole list.
After a submission is stored
Sending a submission to a CRM, adding the address to a mailing list, writing back to the visitor with a gift — these belong to the site, and the module gives them a seam instead of a fork.
The event. WebxUi\Inbox\Events\SubmissionStored is dispatched once the submission, its
answers and its files are written, after the commit, for every source — the form on the site and
one typed in by hand alike ($submission->source). What the antispam trapped or refused is never
written and never announced. The submission comes with form, values and files loaded.
$event->repeated is the same answers again inside the duplicate window — a double click.
It is not an Eloquent created: the row is written before its answers, so a listener of
created would find none of them.
A listener is an ordinary Laravel listener; one that talks to the network is ShouldQueue:
namespace App\Listeners; use Illuminate\Contracts\Queue\ShouldQueue; use WebxUi\Inbox\Events\SubmissionStored; final class TellTheSalesChannel implements ShouldQueue { public function handle(SubmissionStored $event): void { if ($event->repeated || $event->submission->form->slug !== 'contact') { return; } // ... } }
The handlers. For the common case — "this form goes to that service" — name classes in
config/webx-inbox.php, by the form's slug or by * for every form:
'handlers' => [ '*' => [App\Inbox\SyncToMailingList::class], 'gift' => [App\Inbox\SendGift::class], ],
A handler implements WebxUi\Inbox\Contracts\SubmissionHandler and is built by the container, so
it asks for what it needs in its constructor:
namespace App\Inbox; use Illuminate\Support\Facades\Http; use WebxUi\Inbox\Contracts\SubmissionHandler; use WebxUi\Inbox\Models\Submission; final class SyncToMailingList implements SubmissionHandler { public function handle(Submission $submission): void { $email = $submission->value('email')?->value; if ($email === null) { return; } Http::withToken(config('services.mailing_list.token')) ->post('https://api.mailing-list.example/subscribers', [ 'email' => $email, 'fields' => ['name' => $submission->value('name')?->value], 'groups' => [config('services.mailing_list.groups.'.$submission->form->slug)], ]) ->throw(); } }
Each handler runs as a queued job of its own, * first, each class once. Throwing is failing: it
becomes a handler_error line in the submission's log with the message, the others run all the
same, and the visitor never hears of it. Success is a handled line. Both show in the card in the
panel and in inbox_get. A handler is tried once — one that wants retries queues a job of its own
with them — and a double click does not run the handlers again.
Handlers come from the config only, never from a form's options: those are edited in the panel and
over MCP, and the panel does not choose which code runs. On the sync queue they run inside the
visitor's request, so a site that calls slow services runs a queue worker.
For an agent
The section is also six MCP tools, served by webx-ui/mcp at /api/cms/mcp under the scopes
inbox:read and inbox:write:
| Tool | What it does |
|---|---|
inbox_forms_list |
Every form: slug, title, whether it is on, how much is waiting |
inbox_form_get |
One form with its questions — the shape the public door expects |
inbox_form_save |
Create or change a form and its fields in one call |
inbox_list |
The submissions of a form: status, unread, assignee, dates, search |
inbox_get |
One submission: the answers, the files, the metadata, the notes, the log |
inbox_set_status |
The status, the assignee and a note, in one call |
They go through the same rules the panel's editor does, so a slug that is not an address is
refused here too, and everything that changes something takes dry_run: true.
Receiving a submission is not a tool: the intake is a public door with the antispam in front of
it, and a second way in that skipped both would be the one somebody points at a mailing list.
Deleting one is not a tool either — that is webx:inbox:prune, below.
Keeping submissions
php artisan webx:inbox:prune # the ages from the config php artisan webx:inbox:prune --days=365 --dry-run # count what would go, delete nothing
Two ages, because they are two decisions: spam is rubbish and goes after a month, a real enquiry
is a record of a conversation and goes only when a site has said how long it keeps one. Zero means
never, and days is zero by default. Rows go one at a time through the model, so the files on the
disk go with them.
Configuration
php artisan vendor:publish --tag=webx-inbox-config php artisan vendor:publish --tag=webx-inbox-lang
config/webx-inbox.php holds the intake path, the disk and its limits, the antispam defaults,
the captcha keys, the duplicate window and what webx:inbox:prune forgets.
License
MIT.