Validate forms in the browser and enforce rules on the server
Understand native constraints and server-side business rules using an actual executed PHP example.

A form needs two kinds of validation: the browser helps people enter suitable information and the server independently decides what to accept. Both layers should describe the same business task, even when they use different technical mechanisms. A value does not become trustworthy merely because it once passed through a required browser field.
This article demonstrates that distinction with a small project brief containing a reference, project type and planned number of views. Valid entries produce a formatted summary. The example sends no message and stores no order. We can therefore investigate input rules, error feedback and operation without JavaScript without pretending that a customer transaction occurred. Both language editions were checked using PHP and an actual browser.
Write the business contract first
Before choosing HTML attributes, decide which information is required. The reference in this learning example is a technical identifier, not a person's name. It contains three to sixteen ASCII characters. An uppercase letter comes first, followed by uppercase letters, digits or hyphens. That restriction would be inappropriate for a real name. Here it explicitly belongs to the technical field's meaning.
The project type has exactly two accepted values: website and web application. Planned views range from one to fifty. The example additionally requires a canonical sequence of decimal digits without an exponent or leading zero. That is a deliberate example rule. Other applications may permit scientific notation or normalize accepted numbers. What matters is deciding that policy before comparing implementations.
| Field | Business rule | Examples |
|---|---|---|
| Project reference | 3 to 16 characters in the specified technical format | AZ-2026 accepted, az-2026 rejected |
| Project type | One of two known values | website or app as technical values |
| Planned views | Canonical whole number from 1 to 50 | 10 accepted, 01 and 1e1 rejected |
This contract makes later errors easier to diagnose. A missing project type is different from an unknown extra option. An empty number differs from one above the maximum. Users should not need to learn the internal classification of these failures. Feedback should explain how to correct the particular entry they are working on.
Begin with native form controls
The interface uses a real form with the POST method and a submit button. Each control has an associated visible label. The technical reference uses required, minlength, maxlength and pattern. The view count uses type number together with min, max and step. The select has an empty initial option and required.
That foundation already works without custom JavaScript. The browser can report missing values, an unsuitable reference and many number errors before submission. The MDN guide to constraint validation explains these mechanisms. Their purpose in this example is faster feedback. Native message wording and presentation still depend on the browser and its configured language.
Visible instructions appear beside the relevant control. A placeholder alone would be a poor replacement because it can disappear while typing. The select also leaves visible space between its text and arrow. Small details like these affect usability on narrow devices. A technically correct rule provides little help when its instructions are clipped or keyboard focus is difficult to see.
Do not confuse an identifier pattern with natural language
The reference pattern is intentionally narrow. It describes a technical identifier rather than a universal theory of safe characters. A form collecting customer names would need different rules. Apostrophes, accented letters and many other characters are ordinary name data. Removing them indiscriminately could change the information the form is supposed to preserve.
The pattern attribute documentation explains matching the complete value and providing understandable instructions. The example explicitly escapes the hyphen in the HTML character class. The browser rule and PHP expression are not interchangeable text files. Each must actually recognize the intended format under the syntax used by its own implementation.
For these ASCII identifiers, several common ways of counting characters produce the same result. That simplifies the experiment without establishing anything about arbitrary Unicode text. Free-form fields need a considered distinction between bytes, characters and visible character sequences. Adding longer names or descriptions later therefore requires its own justified length policy.
Add the deliberately stricter number notation
An HTML numeric control accepts more than a plain sequence of decimal digits. In the actual browser experiment, 1e1 could satisfy the native conditions without the supplementary script. Its numeric value is ten. Our business contract nevertheless explicitly requires 10. Additional validation consequently checks the canonical representation. This is not evidence that the browser's number interpretation is generally incorrect.
The supplementary function uses setCustomValidity to supply an explanatory message when a nonempty representation fails the agreed format. Required continues to handle an empty value. The function recalculates the custom message on every input event. Once the value is suitable, an empty string clears it. Otherwise an already-corrected entry could remain invalid because an old custom error was never removed.
This enhancement does not replace the ordinary submission route. There is no separate fetch request or second sending handler. The browser still submits a regular form to the same PHP file. That keeps responsibilities understandable. If JavaScript fails or is disabled, the server applies the entire rule anyway and explains the rejected representation.
Why server validation stays independent
The HTML specification's validation section explicitly states that client checks are not a security mechanism. An HTTP request can originate in another program. Attributes can be changed and scripts can bypass checks. The server cannot infer the history of an arriving string from its presence in a request.
The PHP example first checks the request method. GET and HEAD provide the form, POST is processed and other methods receive 405. The three expected fields are then read individually. Each must be a string and stay within a small byte limit before further validation. An array submitted where a scalar was expected must not accidentally turn into an ordinary field value.
Values are then checked explicitly against the business contract. Project type uses an allowlist with strict comparison. Reference and count use complete-value patterns. Additional unknown fields produce a general error in this example. There is no wholesale assignment of an outside POST object into a later data model and no administrator role inferred from an extra form field.
A complete PHP learning example
The following file contains the form, server validation and small browser enhancement together. Translations remain included so both actually tested editions are inspectable. PHP is required to run it. The file connects to neither a mail service nor a database. Its visible result is exclusively the project brief assembled from validated values.
<?php
declare(strict_types=1);
$locale = 'en';
$translations = [
'de' => ['title'=>'Projektbriefing vorbereiten','reference'=>'Projektkennung','reference_help'=>'3 bis 16 Zeichen: Großbuchstabe zuerst, danach Großbuchstaben, Ziffern oder Bindestriche.','service'=>'Projektart','choose'=>'Bitte wählen','website'=>'Webseite','app'=>'Webanwendung','units'=>'Geplante Ansichten','units_help'=>'Ganze Zahl von 1 bis 50, ohne Exponent und führende Null.','submit'=>'Briefing erstellen','errors'=>'Bitte prüfen Sie Ihre Angaben','reference_error'=>'Geben Sie eine Projektkennung im beschriebenen Format ein.','service_error'=>'Wählen Sie eine der angebotenen Projektarten.','units_error'=>'Geben Sie eine ganze Zahl von 1 bis 50 ohne Exponent oder führende Null ein.','form_error'=>'Bitte verwenden Sie die vorgesehenen Eingabefelder.','result'=>'Projektbriefing','method'=>'Diese Anfrageart wird nicht unterstützt.'],
'en' => ['title'=>'Prepare a project brief','reference'=>'Project reference','reference_help'=>'3 to 16 characters: start with an uppercase letter, followed by uppercase letters, digits or hyphens.','service'=>'Project type','choose'=>'Please choose','website'=>'Website','app'=>'Web application','units'=>'Planned views','units_help'=>'A whole number from 1 to 50, without an exponent or leading zero.','submit'=>'Create brief','errors'=>'Please check your entries','reference_error'=>'Enter a project reference in the described format.','service_error'=>'Choose one of the offered project types.','units_error'=>'Enter a whole number from 1 to 50 without an exponent or leading zero.','form_error'=>'Please use the provided fields.','result'=>'Project brief','method'=>'This request method is not supported.']
];
$t = $translations[$locale];
header('Content-Type: text/html; charset=UTF-8');
header('Cache-Control: no-store');
function h(string $value): string { return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if (!in_array($method, ['GET','HEAD','POST'], true)) {
http_response_code(405); header('Allow: GET, HEAD, POST'); echo h($t['method']); exit;
}
$errors = []; $values = ['reference'=>'','service'=>'','units'=>'5']; $result = null;
if ($method === 'POST') {
foreach ($values as $name => $_) {
$raw = $_POST[$name] ?? null;
if (!is_string($raw) || strlen($raw) > 128) {
$errors[$name] = $t[$name.'_error']; $values[$name] = '';
} else { $values[$name] = $raw; }
}
if (array_diff(array_keys($_POST), array_keys($values))) { $errors['form'] = $t['form_error']; }
if (!preg_match('/\A[A-Z][A-Z0-9-]{2,15}\z/', $values['reference'])) { $errors['reference'] = $t['reference_error']; }
if (!in_array($values['service'], ['website','app'], true)) { $errors['service'] = $t['service_error']; }
if (!preg_match('/\A(?:[1-9]|[1-4][0-9]|50)\z/', $values['units'])) { $errors['units'] = $t['units_error']; }
if ($errors) { http_response_code(422); }
else { $result = ['reference'=>$values['reference'],'service'=>$t[$values['service']],'units'=>(int)$values['units']]; }
}
function described(string $name, array $errors): string { return $name.'-help'.(isset($errors[$name]) ? ' '.$name.'-error' : ''); }
function invalid(string $name, array $errors): string { return isset($errors[$name]) ? ' aria-invalid="true"' : ''; }
?>
<!doctype html><html lang="<?= h($locale) ?>"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= h($t['title']) ?></title><style>
*{box-sizing:border-box}body{margin:0;background:#f6f1e8;color:#222129;font:18px/1.5 Arial,sans-serif}main{max-width:760px;margin:auto;padding:30px 24px}h1{font-size:32px;line-height:1.2}label{display:block;font-weight:bold;margin-top:22px}input,select,button{font:inherit;width:100%;height:52px;border:1px solid #777;border-radius:7px;padding:10px 14px;background:white;color:#222}select{appearance:none;padding-right:48px;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Cpath d='M3 5l5 5 5-5' fill='none' stroke='%23222' stroke-width='2'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 16px center}button{margin-top:26px;background:#bd162a;color:white;border-color:#bd162a;font-weight:bold;height:auto;min-height:52px;cursor:pointer}:focus-visible{outline:3px solid #24242c;outline-offset:4px}.help{font-size:15px;margin:8px 0}.error{color:#9e1024;font-weight:bold;margin:8px 0}[aria-invalid=true]{border:2px solid #a71227}.summary{padding:18px 22px;border:2px solid #b51328;background:#fff8f7;border-radius:8px}.summary h2{font-size:23px}.summary a{color:#8e0c1d}.result{padding:20px 25px;background:white;border-left:6px solid #bf162a;margin-top:30px}.result dt{font-weight:bold}.result dd{margin:0 0 12px}
</style><main><h1><?= h($t['title']) ?></h1>
<?php if ($errors): ?><section id="errors" class="summary" tabindex="-1" role="alert"><h2><?= h($t['errors']) ?></h2><ul><?php foreach ($errors as $name=>$message): ?><li><a href="#<?= h($name === 'form' ? 'brief' : $name) ?>"><?= h($message) ?></a></li><?php endforeach ?></ul></section><?php endif ?>
<form id="brief" method="post">
<label for="reference"><?= h($t['reference']) ?></label>
<input id="reference" name="reference" type="text" required minlength="3" maxlength="16" pattern="[A-Z][A-Z0-9\-]{2,15}" value="<?= h($values['reference']) ?>" aria-describedby="<?= h(described('reference',$errors)) ?>"<?= invalid('reference',$errors) ?>>
<p class="help" id="reference-help"><?= h($t['reference_help']) ?></p><?php if (isset($errors['reference'])): ?><p class="error" id="reference-error"><?= h($errors['reference']) ?></p><?php endif ?>
<label for="service"><?= h($t['service']) ?></label>
<select id="service" name="service" required aria-describedby="service-help<?= isset($errors['service']) ? ' service-error' : '' ?>"<?= invalid('service',$errors) ?>>
<option value=""><?= h($t['choose']) ?></option><?php foreach (['website','app'] as $service): ?><option value="<?= h($service) ?>"<?= $values['service']===$service ? ' selected' : '' ?>><?= h($t[$service]) ?></option><?php endforeach ?></select>
<p class="help" id="service-help"><?= h($t['service_error']) ?></p><?php if (isset($errors['service'])): ?><p class="error" id="service-error"><?= h($errors['service']) ?></p><?php endif ?>
<label for="units"><?= h($t['units']) ?></label>
<input id="units" name="units" type="number" min="1" max="50" step="1" required value="<?= h($values['units']) ?>" aria-describedby="<?= h(described('units',$errors)) ?>"<?= invalid('units',$errors) ?>>
<p class="help" id="units-help"><?= h($t['units_help']) ?></p><?php if (isset($errors['units'])): ?><p class="error" id="units-error"><?= h($errors['units']) ?></p><?php endif ?>
<button type="submit"><?= h($t['submit']) ?></button></form>
<?php if ($result): ?><section id="result" class="result" tabindex="-1" role="status"><h2><?= h($t['result']) ?></h2><dl><?php foreach ($result as $name=>$value): ?><dt><?= h($t[$name]) ?></dt><dd><?= h((string)$value) ?></dd><?php endforeach ?></dl></section><?php endif ?>
<script>
const units = document.querySelector('#units');
const message = <?= json_encode($t['units_error'], JSON_HEX_TAG|JSON_HEX_AMP|JSON_HEX_APOS|JSON_HEX_QUOT|JSON_THROW_ON_ERROR) ?>;
function validateUnits() {
units.setCustomValidity(units.value === '' || /^(?:[1-9]|[1-4][0-9]|50)$/.test(units.value) ? '' : message);
}
units.addEventListener('input', validateUnits);
validateUnits();
document.querySelector('#errors, #result')?.focus();
</script></main></html>
After validation succeeds, result values are not collected again from unchecked neighboring fields. The result object is deliberately built from accepted inputs. The view count is converted to an integer only after its representation passes validation. Converting too early could reduce different disallowed strings to an apparently acceptable number. The order of operations therefore belongs to this example's contract.
Keep validation separate from safe output
Rejected input can still be redisplayed in a form. Output therefore needs its own appropriate handling. The h helper calls htmlspecialchars with ENT_QUOTES, ENT_SUBSTITUTE and explicit UTF-8. That handles characters for the HTML text and quoted attribute contexts used here. The PHP documentation describes those options, including behavior with invalid character sequences.
This does not make the helper universal protection for every output destination. SQL statements, JavaScript expressions and arbitrary URL contexts have different requirements. The fixed translated JavaScript message is consequently inserted using json_encode with the relevant HEX options. Each output path is chosen for its actual destination rather than grouped under an ambiguous claim that everything has been “cleaned”.
The OWASP input-validation guidance distinguishes field-specific acceptance rules from other necessary controls. For our form, a valid project reference is a valid reference. That does not make it proof of authorization or permission for arbitrary subsequent actions. This distinction remains important even when the interface is carefully designed.

Explain errors where people need them
A server rejection does not return an empty fresh form here. A summary above the form links to affected controls. Each affected field also has its own concrete message. Entries are retained within the permitted limits. Someone should not have to enter every already-correct value again merely because one number needs correction.
Invalid controls receive aria-invalid and an association with their message through aria-describedby. Existing general instructions remain included in that association. Error meaning is not conveyed through red color alone. The WAI form-notification tutorial describes combined summary and field-level feedback. The experiment also checks those relationships in the actual document structure.
After a server response, the small browser enhancement moves focus to the error summary or generated brief. Without JavaScript, the summary still precedes the form in natural reading order. That is a useful fallback, but it does not establish complete testing with every assistive technology. These experiments include no independent screen-reader user study.
Avoid accidentally bypassing ordinary submission
A later enhancement might need to initiate submission from a script. The submit and requestSubmit methods are not equivalent for that purpose. The MDN requestSubmit documentation explains that requestSubmit follows the submit-button path including validation. A direct submit call does not trigger the same browser checks.
The test deliberately used that bypass. Its script inserted an unsuitable reference and an excessive count and invoked direct submission. The server returned the intended errors. This does not prove that every attack is prevented. It verifies the particular important property that the server's acceptance rules do not depend on an earlier browser check.
Continue usefully without JavaScript
A separate browser context had JavaScript disabled. Native required and pattern conditions remained effective. Valid values still produced the project brief. The broader native representation 1e1 reached the server and received a real 422 response with an associated count error. The number could then be corrected and the brief generated normally.
This does not establish that every feature in every application must look identical without JavaScript. Our specific task needs no exclusively client-side capability. It is therefore useful to keep the basic operation independent of its enhancement. A different application can have different prerequisites. Those should follow from actual functionality rather than an accidentally duplicated submission implementation.
What the executed tests establish
The two language editions passed 228 individual checks in 56 documented cases. The environment used PHP 8.4.27 and an actual Edge browser. Experiments included direct HTTP requests and real form interactions. They covered lower and upper boundaries, missing values, arrays instead of scalar fields, unknown options, extra properties and feedback after bypassed browser validation.
| Situation | Observed outcome | Responsible layer |
|---|---|---|
| Required reference is empty | Ordinary submission is blocked before POST | Native browser validation |
| 1e1 with enhancement | Custom message prevents submission | Browser enhancement |
| 1e1 without JavaScript | 422 and a concrete number error | Server validation |
| Directly submitted array | No successful brief is generated | Server validation |
| Corrected valid values | Formatted project summary appears | Validated server workflow |
The test also checked field/error associations, actual focus movement and layout at a 390-pixel viewport. No horizontal page overflow occurred in that narrow view. Select arrows retain visible inner spacing. These findings describe the examined interface. They do not replace systematic coverage of all browsers, assistive technologies or form variants added later.
Assess additional boundaries before real mutations
This learning form only formats information. It has no authenticated user and changes no existing business record. When a real form stores data or performs a privileged action, additional questions appear. They include user authority, protection against unintended triggering, repeated requests and truthful success feedback after the actual operation. Passing field checks does not settle these questions.
Request-size and abuse limits are likewise not completely implemented by the PHP field helper. The web server and input parser have already processed the request before the code examines field length. File uploads, large forms and heavily exposed endpoints need appropriate limits across the entire intake path. A small regular expression does not replace an operational design.
Make later changes traceable
The field contract should remain visible when requirements change. Raising the maximum from fifty to another value affects the help text, HTML attributes, supplementary expression, PHP rule and boundary tests. Changing only one of these creates avoidable contradictions. In this example, all rules are small enough to inspect together. Larger applications need an explicit way to keep those related definitions synchronized.
Translations require the same care. The accepted technical values can remain stable while visible labels change between languages. A translated label should not silently become a new backend enum. Error text must continue to describe the rule actually enforced. Inspecting the rendered page as well as code is useful because a correct translated sentence can still wrap badly in the available width.
Finally, distinguish a rejection from a lost operation. Our 422 response follows invalid submitted values and generates no brief result. A later storage failure after valid input would be a different event. Its feedback and retry policy should reflect that difference. Reusing a generic “please check your fields” message for every failure can lead people to edit correct information while the real problem remains elsewhere.
Preserve the boundaries of the example task
An accepted reference does not prove that a project with that identifier exists. That would require consulting the relevant authoritative data. A valid project type does not establish that the person choosing it has access to an actual project either. This example checks only the submitted information's format. Confusing format, existence and authorization becomes particularly problematic when a small form later grows into customer administration.
Likewise, fifty planned views do not constitute a binding service specification. The number may be sufficient for an initial brief and too imprecise for a contract decision. Business rules should match the actual subsequent use. Recording what each result can support and which questions remain open prevents a technically successful submission from appearing to confirm a wider commercial agreement.
The deliberately stateless workflow has another clear limit. Resubmission simply generates the same summary again. For a real booking or sending operation, a repeated request might instead create a second action. Those consequences need their own treatment when the form grows. Merely making a button look disabled after the first click does not resolve repeated server requests.
Checklist for the next form change
- Describe every field's business meaning and accepted representation.
- Keep technical identifiers distinct from names and free text.
- Provide native labels, visible instructions and required conditions.
- Clear supplementary browser errors after a correction.
- Validate type, size and business values independently on the server.
- Do not let arrays or unknown options produce success.
- Build results deliberately from accepted values.
- Handle output for its actual destination context.
- Make errors discoverable both in a summary and beside controls.
- Actually test disabled JavaScript and bypassed browser checks.
- Describe only actions that genuinely occurred in success feedback.
- Add appropriate boundaries for later storage or sending operations.








