marcel-maqsood / mezzio-middleware-formhandler
The middleware formularhandler is a PSR-15 middleware that provides handling from formular data in a Laminas/Mezzio application
Package info
github.com/marcel-maqsood/Mezzio-Middleware-FormHandler
pkg:composer/marcel-maqsood/mezzio-middleware-formhandler
Requires
- php: >=8.0 <9.0
- laminas/laminas-diactoros: ^3.0
- laminas/laminas-json: ^3.1
- laminas/laminas-servicemanager: ^3.4
- mezzio/mezzio-problem-details: ^1.0
- psr/container: ^1.0
- psr/http-server-middleware: ^1.0
- swiftmailer/swiftmailer: ^6.2
- true/punycode: ^2.1
- twig/twig: ^3.9.3
Requires (Dev)
- mezzio/mezzio-csrf: ^1.5
- mezzio/mezzio-template: ^2.8
- phpunit/phpunit: ^9.6
- squizlabs/php_codesniffer: ^3.7
README
This library allows you to handle your forms, check for missing fields and only uses fields that you really expect to be submitted.
Installation
Run the following to install this library:
composer require marcel-maqsood/mezzio-middleware-formhandler:^1.2
Info
In validated mode, fields that are not defined inside the config are removed before the request reaches the next handler. The default legacy mode retains the historical v1 null-adapter behaviour and forwards its raw form payload.
adapter is no longer the correct keyword for DataAdapters, the key is now adapters as the Formhandler is now able to use multiple adapters in one go.
BE AWARE: if adapters contain an entry 'null', that no other adapter after that entry will be used as null passes the data down the pipe.
Validation modes
The package has two explicitly documented validation modes:
'mazeform' => [ 'validation' => [ 'mode' => 'validated', ], 'forms' => [ // form definitions ], ],
legacyis the default whenvalidation.modeis omitted. It preserves the v1 contract, including rawformDataforwarding foradapters => nulland the downstreamcsrfErrorattribute for a submitted invalid token.validatedis opt-in. It validates and filters every configured form before any adapter or downstream handler receives it, including forms withadapters => null.
Only an omitted validation.mode selects legacy. An explicitly configured
unknown or non-string mode is a configuration error and returns HTTP 400; a
typo must never silently disable validation.
Use validated for new integrations. Existing applications can update the
package without changing their payloads or configs and enable the stricter
contract after their form definitions have been audited.
Validated request contract
The standard envelope remains unchanged:
[
'data' => [
'config' => 'saveProfile',
'name' => 'Ada',
],
]
For compatibility with downstream request handlers, validated mode also
accepts config and the fields directly at the top level. A POST without both
data and config is not considered a FormHandler request and continues down
the middleware pipeline unchanged. The same applies to an empty POST body or
an empty parsed body. An explicit data => [] envelope remains a malformed
FormHandler request in validated mode and returns HTTP 400; legacy mode retains
the historical loose-null pass-through for data values null, false, 0,
0.0, an empty string, and an empty array.
On success, the next handler receives the request attribute formData:
[
'config' => 'saveProfile', // reserved dispatch value, always retained
'name' => 'Ada', // declared field, original representation retained
]
Unknown fields are silently removed. Values are validated but never trimmed,
cast, or otherwise converted. The request also receives
formValidationPassed => true and csrfValidated => true|false.
Malformed payloads, missing/unknown form configs, unavailable route variants, missing required fields, and invalid field types return a controlled HTTP 400 Problem Details response without invoking adapters or the next handler.
Required, non-empty, and typed fields
required => true checks key presence only. Empty strings remain valid for
backwards compatibility. Add notEmpty => true when a submitted value must
contain content:
'password' => [ 'type' => 'text', 'required' => true, 'notEmpty' => true, ],
notEmpty rejects null, strings that are empty after trim(), and empty
arrays. The values 0, '0', and false are not considered empty. Optional
fields may be omitted; when present, their type and constraints are validated.
Validated mode supports these types:
Every declared field needs one of these type values when validated mode is
enabled. Exact and nested field definitions are checked eagerly, including
optional fields that are absent from the current request. required and
notEmpty must be real booleans; malformed schemas return HTTP 400 before an
adapter or downstream handler runs.
text: stringint: integer or signed integer stringemail: valid email stringfloat/numeric: integer, float, or numeric stringbool/boolean: boolean,0/1, or the supported string equivalents0,1,true,false,on, andoffarray: arraycsrf: non-empty string which is additionally checked by the CSRF guard
An empty string is accepted for non-CSRF types unless notEmpty => true is
configured. This preserves browser form conventions for optional fields.
Nested fields and wildcard items
For one nested associative array, use either the historical childs key or
the equivalent fields key. Unknown nested keys are filtered as well:
'profile' => [ 'type' => 'array', 'fields' => [ 'name' => ['type' => 'text', 'required' => true], ], ],
Use items to validate every item in a list. It accepts a scalar item
definition or a nested field schema:
'ids' => [ 'type' => 'array', 'items' => ['type' => 'int'], ], 'rows' => [ 'type' => 'array', 'items' => [ 'type' => 'array', 'fields' => [ 'id' => ['type' => 'int', 'required' => true], 'label' => ['type' => 'text'], ], ], ],
childs, fields, and items can be nested recursively.
Dynamic field names
When browser field names are generated from IDs or type numbers, declare an
optional fieldPatterns map on the form instead of widening the allowlist:
'assignModules' => [ 'adapters' => null, 'fields' => [ 'csrf' => ['type' => 'csrf', 'required' => true], ], 'fieldPatterns' => [ '/^type[1-9][0-9]*moduleIds$/D' => [ 'type' => 'array', 'notEmpty' => true, 'items' => ['type' => 'int'], ], '/^type[1-9][0-9]*moduleData$/D' => [ 'type' => 'array', 'fields' => [ 'id' => ['type' => 'int', 'required' => true], 'label' => ['type' => 'text'], ], ], ], ],
Each map key is a PCRE and each value is a normal field definition supporting
the same type, notEmpty, childs, fields, and items rules as an exact
field. required is evaluated after a concrete dynamic key matched; it cannot
require an otherwise unknown field name to exist.
Exact fields always take precedence, even when their name also matches a
pattern. Every other top-level request field is checked against the configured
patterns. No match means the field is removed. More than one match is treated
as an ambiguous configuration and returns HTTP 400, so array order never
decides which schema wins.
For security, patterns must compile and be explicitly, absolutely anchored:
- begin with
^or\A; - end with
\z, or with$and the PCREDmodifier; - match the complete field name at offset zero.
Invalid and unanchored patterns return HTTP 400 before adapters or the next
handler run. The full-match check is enforced in addition to the anchor check.
fieldPatterns is ignored in legacy mode and therefore does not change
existing consumers.
Route-specific form schemas
One config name can use different allowlists on different routes. Route
variants live inside the form definition and replace the corresponding base
keys, most commonly fields and fieldPatterns:
'save' => [ 'adapters' => null, 'fields' => [ 'publicValue' => ['type' => 'text', 'required' => true], ], 'routes' => [ 'admin.profile.save' => [ 'fields' => [ 'userId' => ['type' => 'int', 'required' => true], 'role' => ['type' => 'text', 'required' => true], ], 'fieldPatterns' => [ '/^group[1-9][0-9]*Ids$/D' => [ 'type' => 'array', 'items' => ['type' => 'int'], ], ], ], ], ],
Routing must run before FormHandler. When a route is matched and a form defines
routes, a missing variant for that route returns HTTP 400. If no route result
is available, the base definition is used. Route detection follows the
getMatchedRouteName() contract and does not add a hard router dependency.
Documentation
At the bottom of the Doc, i'll show you a quick example on how the config is build like.
The Implementation
To implement the middleware, add a route to your routes file that passes its request into the middleware:
$app->route(
'/formhandler[/]',
[
MazeDEV\FormularHandlerMiddleware\FormularHandlerMiddleware::class,
],
['POST'],
'formHandler'
);
Since our FormHandler is now a real middleware, you can even implement it like this:
$app->route( '/formhandler[/]', [ MazeDEV\FormularHandlerMiddleware\FormularHandlerMiddleware::class, App\Handler\YourHandler::class ], ['POST'], 'formHandler' );
after that, be sure to provide a config-file inside your config/autoload folder, that contains anything the Middleware needs to check your forms.
We recommend you to use our config-file /config/form-config.local.php paste it into your /config/autoload/ folder and adjust it to fit your needs.
Needed Data
The Formhandler needs either JSON-, Multidata- or plain POST Requests to run properly and it responds with JSON, describing whats going on.
- You can either adress the FormHandler by submitting your form the regualr way (button type="submit" form method="post") or via AJAX. if you go through AJAX, you can use our base JavaScript to prepare the form in accordance to the Handlers needs.
Important Notes:
-
Included in this Project, there is a basic JavaScript
/js/FormToJSON.jsthat is important to send data to the FormHandler when using AJAX Requests. You can implement your own logic but you may need it to begin with. -
It is essential to define an
<input type="hidden" name="data[config]" value="YourFormName">field inside your form. This field provides our FormHandler with the necessary information about the form it must validate against. -
Also, each Input must begin with "data", like this:
<input type="hidden" name="data[config]" value="aValue"/>. Failure to follow this format will result in the input not being recognized by our FormHandler.
HTML Example
```html
<form id="aId" method="post">
<input type="hidden" name="data[config]" value="aValue"/>
<div class="row mb-1">
<div class="col-6">
<input id="surname" name="data[nachname]" type="text" class="form-control bg-dark"
placeholder="Surname" required/>
</div>
<div class="col-6">
<input id="Name" name="data[name]" type="text" class="form-control bg-dark"
placeholder="Name" required/>
</div>
</div>
<div class="row mt-3">
<div class="col-12">
<div class="form-group">
<label class="text-muted label-center">Empty Fields will be ignored.</label>
<button id="submit" type="submit" class="btn btn-success w-100">Submit</button>
</div>
</div>
</div>
</form>
```
The Adapters
Currently there are 3 working Adapters:
- phpmail
definition looks like this:'adapters' => [ 'phpmail' => [ 'reply-to' => [ 'status' => true, 'field' => 'mail', ], 'recipients' => ['example@example.com'], 'subject' => 'subject', 'sender' => 'sender@example.com', 'senderName' => 'Form', 'template' => 'app::test', ], ],
phpmail sends the mail (as you may expect) via the php method: mail(). - smtpmail
definition looks like this:'adapters' => [ 'smtpmail' => [ //same as on phpmail but includes: 'email_transfer' => [ 'method' => 'smtpmail', 'config' => [ 'service' => 'smtp.googlemail.com', 'port' => '465', 'encryption' => 'ssl', 'email' => 'example@gmail.com', 'password' => 'examplepw', ], ], ], ],
smtpmail sends the mail via Swift_SmtpTransport. you can implement them (as later described) as global or as local ones, global adapters do overwrite the local ones. - null
definition looks like this:'adapters' => null,If you define'adapters' => null, FormHandler passes the form onto the next handler in your route. Invalidatedmode this is the filtered, validatedformData; in the defaultlegacymode it remains the historical raw form payload.
The Local-Adapters
The Adapter field must be directly inside the form-definition:
'forms' => [ 'contact' => [ 'fields' => [ ... ], 'adapters' => [ null ], ], ],
The Global-Adapters
A Global Adapter is defined in the very top of the config:
'mazeform' => [ 'adapters' => [ 'globalTestAdapter-1' => [ [ 'method' => 'smtpmail', 'recipients' => ['example@example.com'], 'subject' => 'base subject all forms that uses this specific adaper has', 'sender' => 'example@example.com', 'senderName' => 'form', 'template' => 'app::test', 'email_transfer' => [ 'config' => [ 'service' => 'smtp.googlemail.com', 'port' => '465', 'encryption' => 'ssl', 'email' => 'example@gmail.com', 'password' => 'examplepw', ], ], ], ], ], ],
A named global adapter may contain either one adapter definition directly or
a list of adapter definitions as shown above. Named references are expanded in
place in the form's adapters list, so a global list does not become a nested
array. Inline definitions, named references, and null may be mixed; their
order and the existing null stop semantics are preserved in both validation
modes.
if you defined a global adapter and want to use it, go ahead and put the name of it (in this case: globalTestAdapter-1) inside of the adapter-field of your form-config:
'forms' => [ 'contact' => [ 'fields' => [ ... ], 'adapters' => [ 'globalTestAdapter-1', 'secondAdapter', null ], ], ],
Recipients
As your forms should be able to send automated responses, you can define any number of recipients within php 'recipients' and also use '%submit%' so that the handler will map this variable to the first "email" field submitted within your form, eg: max.mustermann@mustermail.de so that max knows that your system took notice.
The EMail-Template
The Template, you specified in the Config can be dynamic through twig, however since v1.0.23, the template field must contain a valid template's name:
'template' => 'app::test'
the variables you use must be valid fields of your form and also defined in your config.
The EMail-Subject
Like the EMail-Template, also the EMail-Subject do support the twig-renderer, however the Subject does not support template-names, its a plain string:
'subject' => 'base subject all forms that uses this specific adaper has {{ some_twig_variable }}'
The Reply-To Header
The E-Mail Adapters can handle with the "Reply-To" email-header you can define it inside the Adapter config like this:
'reply-to' => [ 'status' => true, 'field' => 'mail' ],
reply-to only works if:
- reply-to is defined and the following is correct;
- Status is defined and true;
- field is defined (and exists in config) or not defined (but then one field of your config must be type of email):
'forms' => [ 'contact' => [ 'fields' => [ 'someFieldName' => [ 'type' => 'email', ], ], 'adapters' => [ ... ], ], ],
CSRF Protection
As your Forms will at some point either be stored within a database or send via email, our FormHandler can check for CSRF-Tokens to protect your application from getting bloated.
To use CSRF Protection, you have to install Mezzio-CSRF, run its middleware before FormHandler, and configure it correctly.
In validated mode, a form may declare at most one csrf field, and it must be
an exact top-level entry in fields. CSRF definitions inside childs,
fields, items, or fieldPatterns are configuration errors. The configured
field is mandatory regardless of its required setting. A missing guard,
missing token, invalid token, or reused token returns HTTP 403 and never invokes
an adapter or downstream handler. If the guard can generate a token, the
Problem Details payload contains a fresh csrf value for the next attempt.
In legacy mode, retain 'required' => true when token presence must also be covered by the historical presence check.
After that, be sure to define one of your fields as 'type' => 'csrf':
'contactForm' => [ 'fields' => [ 'somename' => [ 'required' => true 'type' => 'csrf', ], ] ]
In legacy mode, a submitted invalid CSRF token is passed down the pipe with the additional attribute 'csrfError' for backwards compatibility:
if($request->getAttribute('csrfError') != null) { //your error handling goes here... }
Multi-Layer Submit Arrays
As your application might send and receive nested arrays, eg. on contact-forms, our handler is able to retrieve an email field from within a nested arrays:
You only need to define a field as 'type' => 'array' if you want to use this behaviour.
'contactForm' => [ 'fields' => [ 'contact' => [ 'type' => 'array', 'childs' => [ 'salutation' => [ 'required' => true, 'type' => 'text' ], 'name' => [ 'required' => true, 'type' => 'text' ], 'surname' => [ 'required' => true, 'type' => 'text' ], 'email' => [ 'required' => true, 'type' => 'email' ], 'tel' => [ 'required' => true, 'type' => 'int' ], ] //in this case, there is no ], ], 'adapters' => [ ... ], ],
The Required-Attribute
The FormHandler can check whether a field key is present. In validated mode a missing required key returns HTTP 400. Empty strings intentionally satisfy required; combine it with notEmpty => true when blank input must be rejected.
If you don't define required, or set it to false, the field may be omitted from the request.
If you want to set a field as required add this into the config of the field:
'required' => true
The Example
'mazeform' => [ 'adapters' => [ 'globalExampleAdapter-1' => [ [ 'method' => 'smtpmail', 'recipients' => ['recipient@example.com'], 'subject' => 'base subject all forms that uses this specific adaper has', 'sender' => 'example@example.com', 'senderName' => 'Kontaktformular', 'template' => 'app::test', 'email_transfer' => [ 'config' => [ 'service' => 'smtp.example.com', 'port' => '465', 'encryption' => 'ssl', 'email' => 'example@example.com', 'password' => 'yourPassword', ], ], ], ], ], 'forms' => [ 'contact' => [ 'fields' => [ 'name' => [ 'required' => true, ], 'company' => [ ], 'street' => [ ], 'city' => [ ], 'country' => [ 'required' => true, ], 'phone' => [ 'required' => true, ], 'mail' => [ 'required' => true, 'type' => 'email', ], 'message' => [ 'required' => true, ], 'somefield' => [ 'required' => true, 'type' => 'csrf' ] ], 'adapters' => [ 'globalExampleAdapter-1', null ], ], 'otherForm' => [ 'fields' => [ 'name' => [ 'required' => true, ], 'company' => [ ], 'street' => [ ], 'city' => [ ], 'country' => [ 'required' => false, ], 'phone' => [ 'required' => true, ], 'mail' => [ 'required' => true, 'type' => 'email', ], 'message' => [ 'required' => true, ], 'somefield' => [ 'required' => true, 'type' => 'csrf' ] ], 'adapters' => [ [ 'method' => 'phpmail', 'reply-to' => [ 'status' => true, 'field' => 'mail' ], 'recipients' => ['example@example.com'], 'subject' => 'example subject', 'sender' => 'sender@example.com', 'senderName' => 'Form', 'template' => 'app::test', ], [ 'method' => 'smtpmail', 'recipients' => ['recipient@example.com'], 'subject' => 'base subject all forms that uses this specific adaper has', 'sender' => 'example@example.com', 'senderName' => 'Kontaktformular', 'template' => 'app::test', 'email_transfer' => [ 'config' => [ 'service' => 'smtp.example.com', 'port' => '465', 'encryption' => 'ssl', 'email' => 'example@example.com', 'password' => 'yourPassword', ], ], ], ], ], ], ],
Credits
This bundle has been developed by designpark and was forked by ElectricBrands. To maintain this project without further mess, it is now forked onto my own github. (It was mainly me who developed it anyways).
License
The MIT License (MIT). Please see License File for more information.