Software & practice

Validate input and encode for its output context: a PHP example

Define structure, Unicode limits and business rules at the input boundary and handle values appropriately at every output.

AI-generated symbolic illustration of HTML output encoding: a supported red ampersand sculpture is paired with its literal HTML source spelling & on a supported glass plaque.

A value can satisfy a business rule and still be dangerous when placed in the wrong output context. A link can be correctly escaped as an HTML attribute while naming an unwanted protocol. A well-formed identifier can belong to a record the requester is not permitted to access. These situations explain why validation, output handling and authorisation are separate responsibilities.

This guide develops that distinction through a small PHP text preview. It accepts a name, a message and an optional HTTPS address. It sends no messages and changes no stored state. Both language editions were actually executed under PHP 8.4.27 and checked in a browser. The examples demonstrate specific controls rather than claiming complete application security. Sources and technical explanations were reviewed on 9 November 2026.

Ask a different question at each boundary

At the input boundary, the application checks whether a value matches the agreed data model. Before a business action, it separately checks whether the action is allowed and the relevant records may be used. At the output boundary, it ensures the value is interpreted as data in its intended context. None of these decisions completely replaces the others.

ResponsibilitySpecific questionWhat it does not establish
Structural validationIs the message one string rather than a nested array?Whether its claim is accurate or appropriate to publish
Business validationDoes the end date follow the start date?Whether this user may change the booking
AuthorisationMay this account edit this particular record?Whether the subsequent HTML output is encoded correctly
Contextual output handlingDoes this value remain text inside this HTML element?Whether the text describes a true fact

The OWASP input validation guide distinguishes structure, business rules and additional protections. That separation also helps diagnose defects. A business inconsistency is not repaired with a new escaping function. An unauthorised action does not become permitted because its parameters look tidy.

Define the input contract before building the form

The preview reads three named fields. Name and message are required. Website is optional and, when supplied, must meet the declared HTTPS policy. Each field expects one string. An additional submitted field is not automatically copied into an application object. In particular, an unexpected field called role does not create a different user role.

The example's business limits are 80 Unicode code points for the name,1000 for the message and 2048 for the address. Before that check, every read field also has a 4096-byte limit. These measurements are deliberately different. A sequence containing emoji can have the same code-point count as ASCII text while requiring substantially more bytes.

These figures are choices for a learning preview, not universal requirements for contact forms. Agree the actual rules with the product team. If the interface needs to support particular names or messages, technical defaults should not silently exclude them. A clear contract also gives reviewers a concrete basis for deciding whether a rejected input is correct behaviour or a defect.

Browser constraints do not replace server validation

The HTML required attribute supports immediate form feedback. It cannot stop another client sending a different request. Required values are therefore checked again on the server. The example's error summary is based on server-assessed values and remains meaningful when a request is submitted directly rather than through the ordinary form.

A frequent discrepancy is an array where text was expected. In PHP, a request containing name[]=Ada produces a different structure from name=Ada. The application should not discover that mismatch only when passing the value to a string function. The example checks the type explicitly and does not continue with a supposedly repaired fragment of the wrong structure.

The same principle applies to missing properties, null, arrays and nested objects in JSON interfaces. A schema must describe the forms the application actually accepts. Successfully decoding JSON establishes that the document is syntactically valid JSON. It does not establish the expected data model or permission for a business operation. Keep those subsequent decisions visible.

Make normalisation an explicit product decision

The preview normalises line endings and trims outer whitespace. It then checks remaining disallowed control characters. Messages may contain line breaks, while the name is intended to stay on one line. These rules are visible in the code. They should not become an invisible repair operation that changes everything the person wrote.

The code counts Unicode code points rather than user-perceived characters. A letter followed by a combining accent may occupy several code points. Understand that distinction before adding a counter and explanatory message to the interface. A product that wants to count grapheme clusters needs a matching shared policy and an appropriate implementation.

The example does not automatically convert text to a particular Unicode normalisation form. Such a policy can matter for consistent identifier comparisons, but it should be defined before validation and storage. Do not introduce it into free text as an unexplained security cleanup. The accepted representation and the representation used later need to remain consistent.

Do not search for a short list of bad words

The example does not strip apostrophes or reject every angle bracket. A value containing those characters remains text. Safe presentation is provided at the output boundary. Banning isolated characters would damage legitimate input without providing complete XSS protection. A name containing an apostrophe makes that damage immediately apparent.

An acceptance rule instead describes the form the application needs: one UTF-8 string, a length range and, where relevant, membership in an allowed set. A fixed selection field can be checked against its exact permitted values. A free message requires a different rule from a stock identifier or calendar date. One global cleanup function cannot meaningfully represent all those decisions.

The preview's message is explicitly plain text. A product that genuinely accepts formatted HTML has a different contract. It needs a maintained sanitisation solution configured for the permitted structures. A homemade list of tags to remove or a regular expression is not an equivalent control. Changing to rich content should trigger a deliberate review of the entire data path.

Encode HTML text and quoted attributes deliberately

The html helper calls htmlspecialchars with explicit flags and UTF-8. It is used where values enter HTML text or fully quoted, deliberately chosen attributes. The PHP htmlspecialchars documentation describes the converted characters and treatment of invalid encoding sequences.

A name such as <b>Text</b> consequently appears as a sequence of characters rather than a new HTML element. In a value attribute, an incoming quote must not end the attribute. The browser should still read one form value. This depends on a controlled HTML structure and fixed attribute names as well as the encoder itself.

The helper is not permission to place data anywhere in a page. An event-handler attribute, a JavaScript expression and a dynamic CSS declaration have different interpretation rules. Often the better design is to avoid such construction and pass data through a clearly bounded channel. The function's name should not conceal its limited purpose.

Actual PHP and browser flow: a text name containing b tags and quotation marks remains valid, is HTML-encoded in source and appears literally without child elements in the preview. A javascript URL is rejected separately. German labels.
The unchanged PHP example accepts the name as text and encodes it for the HTML text context. The browser displays the sequence literally and creates no b element inside it. Message value 0 is preserved. A javascript address is separately rejected as an invalid website value. These two isolated form submissions neither store nor send anything.

A complete PHP text preview

The following file is a self-contained learning page. It includes validation, the form and the conditional preview. The textField function receives only the parameters it needs and collects errors in a separate array. Excessively large or invalidly encoded input is not reflected back in full. Ordinary validly read values can remain available when another field fails its business rule.

<?php
declare(strict_types=1);
header('Content-Type: text/html; charset=UTF-8');
header('Cache-Control: no-store');
header('X-Content-Type-Options: nosniff');

function html(string $value): string {
    return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

function textField(array $input, string $key, int $maxChars,
                   bool $required, bool $multiline, array &$errors): string {
    $value = $input[$key] ?? '';
    if (!is_string($value)) {
        $errors[$key] = 'Please provide one text value.';
        return '';
    }
    if (strlen($value) > 4096 || preg_match('//u', $value) !== 1) {
        $errors[$key] = 'The value is too long or is not validly encoded.';
        return '';
    }
    $value = str_replace(["\r\n", "\r"], "\n", $value);
    $value = trim($value);
    $controls = $multiline ? '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/'
                           : '/[\x00-\x1F\x7F]/';
    if (preg_match($controls, $value) === 1 ||
        preg_match_all('/./us', $value) > $maxChars ||
        ($required && $value === '')) {
        $errors[$key] = 'Please provide text within the allowed length.';
    }
    return $value;
}

$errors = [];
$values = ['name' => '', 'message' => '', 'website' => ''];
$submitted = ($_SERVER['REQUEST_METHOD'] ?? '') === 'POST';
if ($submitted) {
    $values['name'] = textField($_POST, 'name', 80, true, false, $errors);
    $values['message'] = textField($_POST, 'message', 1000, true, true, $errors);
    $values['website'] = textField($_POST, 'website', 2048, false, false, $errors);
    if ($values['website'] !== '' && !isset($errors['website'])) {
        $parts = parse_url($values['website']);
        if (filter_var($values['website'], FILTER_VALIDATE_URL) === false ||
            !is_array($parts) || strtolower($parts['scheme'] ?? '') !== 'https' ||
            empty($parts['host']) || isset($parts['user']) || isset($parts['pass'])) {
            $errors['website'] = 'Please provide an HTTPS address without credentials.';
        }
    }
}
$ready = $submitted && $errors === [];
$json = $ready ? json_encode($values,
    JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR) : null;
?>
<!doctype html><html lang="en"><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Text preview</title>
<style>body{font:1rem/1.5 system-ui;margin:1rem auto;padding:0 1rem;max-width:42rem}label{display:block;margin-top:1rem}input,textarea,button{font:inherit;box-sizing:border-box;max-width:100%}input,textarea{width:100%;padding:.6rem}button{padding:.6rem 1rem;margin-top:1rem}#preview-message{white-space:pre-wrap;overflow-wrap:anywhere}#preview-name,a{overflow-wrap:anywhere}:focus-visible{outline:3px solid #be2028;outline-offset:3px}</style>
<h1>Create a text preview</h1>
<?php if ($errors): ?><section aria-labelledby="errors-title"><h2 id="errors-title">Please check your input</h2><ul>
<?php foreach ($errors as $field => $error): ?>
<li><a href="#<?= html($field) ?>"><?= html($error) ?></a></li>
<?php endforeach; ?></ul></section><?php endif; ?>
<form method="post" action="">
<label for="name">Name</label>
<input id="name" name="name" value="<?= html($values['name']) ?>" required>
<label for="message">Message</label>
<textarea id="message" name="message" rows="5" required><?= html($values['message']) ?></textarea>
<label for="website">Website as an HTTPS address, optional</label>
<input id="website" name="website" value="<?= html($values['website']) ?>">
<button>Create preview</button>
</form>
<?php if ($ready): ?><section id="preview" aria-labelledby="preview-title">
<h2 id="preview-title">Your text preview</h2>
<h3 id="preview-name"><?= html($values['name']) ?></h3>
<p id="preview-message"><?= html($values['message']) ?></p>
<?php if ($values['website'] !== ''): ?>
<a id="preview-link" href="<?= html($values['website']) ?>" rel="noreferrer noopener"><?= html($values['website']) ?></a>
<?php endif; ?></section>
<script id="preview-data" type="application/json"><?= $json ?></script>
<?php endif; ?></html>

The preview appears only after the defined field checks have completed successfully. If a check fails, the form remains visible with a linked error summary. A valid value is not described as a sent message: the application creates only the announced text preview. That functional boundary prevents a success claim for an action that never happened.

The page changes no stored state. It is consequently not a complete contact service, user account or publication workflow. Adding those capabilities would introduce further requirements around authorisation, abuse controls, failure handling and, where appropriate, protection of state-changing requests. The learning file should not be presented as an unexamined foundation for every production form.

An HTTPS address needs more than HTML escaping

The optional address is first checked as a single text value. The example then checks URL syntax, the explicitly permitted https scheme and the presence of a host. Embedded credentials are rejected. Only after those checks is the value encoded for the quoted href attribute. The validation policy and output encoding therefore perform different jobs on the same value.

The filter_var documentation matters here: its default filter does not perform general validation. The example explicitly requests FILTER_VALIDATE_URL. That filter still does not replace the additional product rules concerning schemes and embedded credentials.

A structurally acceptable HTTPS URL is not evidence that the destination is trustworthy. It may contain misleading material. The preview does not fetch the address from the server. If an application later retrieves remote preview data, it introduces another assessment area, including unwanted server requests. The existing link policy must not be rebranded as complete protection for that new retrieval service.

Generate JSON for its actual embedding context

The valid preview values are also provided as JSON data in a script element whose type is application/json. They are not an executable JavaScript statement there. Nevertheless, submitted text must not terminate the surrounding HTML structure. The selected JSON_HEX options keep the relevant characters from appearing literally in this embedding.

The PHP json_encode documentation explains these options and error handling. JSON_THROW_ON_ERROR makes an encoding failure observable. Applying htmlspecialchars afterwards would not be a suitable replacement: the script data block is not interpreted as ordinary HTML text for that purpose. Encoding must match the enclosing context rather than merely making the source appear reassuring.

A standalone JSON response would require the appropriate response type and a separate interface contract. This code specifically demonstrates data embedded in an HTML page. It does not promise one universal JSON helper suitable for every transfer mechanism. Keep that distinction when reusing the example in another component.

Preserve the text contract on the subsequent DOM path

If JavaScript later reads the JSON data, the checked string should not suddenly become HTML. Assigning it through textContent preserves the plain-text contract. Passing it to innerHTML would introduce another interpretation boundary. Server-side encoding applied at a different output location does not automatically protect that new step.

The OWASP XSS prevention guide explains contextual handling and appropriate output sinks. A clear data flow identifies its source, transformations and final destination. Saying that the value was cleaned earlier is not an adequate description of the last operation.

The same care applies to component libraries. A helper that later converts a string into HTML changes the contract. Inspect the real use rather than relying only on a variable's name or declared type. Intentionally accepted HTML needs its own documented sanitisation policy and limits. The distinction between text and markup should survive every handover.

What the executed checks establish

Both PHP files were first syntax checked and then called through an actual PHP test server. Each language edition processed 24 different requests. Cases included ordinary Unicode names, the string 0, missing required values, arrays instead of strings, invalid UTF-8 and the agreed length boundaries. This tests both unusual permitted input and forms the contract intentionally rejects.

Additional cases included HTML-like text, quotes, a closing textarea sequence and a closing script sequence. Responses were interpreted in a browser. Checks established that the preview and JSON preserved the intended values without creating injected elements or event attributes. The preview had no Content Security Policy masking an encoding failure during these tests.

Both forms were also filled in and submitted through actual browser POST navigation. The narrow layout was checked for horizontal overflow. In total,242 conditions passed across 48 server-processed cases and two additional browser form flows. These figures describe the performed experiment. They are not a certification of the whole application or a claim that no other input can reveal a defect.

Explain errors without unnecessary loss of valid input

A validly read value remains visible when a business validation rule fails. Output encoding happens as it is displayed. The person therefore does not have to rewrite every other field merely because the optional address failed the declared policy. Structurally wrong or excessively large input, however, is not blindly reflected as a huge form value.

The error summary links to the affected fields. Its messages come from a controlled list instead of including the complete failed request. This helps readability as well as handling unwanted content. More complex forms may require field-specific associations and deliberate focus handling. Those additions should be tested with actual navigation rather than assumed correct from the presence of labels.

Describe the rules the server really applies. A user-perceived character counter would not precisely match the code-point limit used here. The interface and server should communicate the same product decision. Their disagreement is a usability problem even when the output encoder is correct and the form rejects invalid values consistently.

Respect the limits of the small implementation

The 4096-byte field limit is checked after PHP has read the request. It does not bound the network body or the earlier parser. A public service needs suitable limits before and during processing. This example does not claim protection against arbitrarily large requests or every form of resource abuse. Keep that scope visible when moving a learning file into a real deployment.

Later database storage needs parameterised statements and appropriate data-model checks. HTML encoding is not an SQL defence. A state-changing feature needs assessment of the allowed operation and any further request protections. A valid identifier does not establish that the account may use the record it identifies. The form's acceptance result should not be mistaken for an access-control decision.

Logs are outputs too. If external values are later shown in an administrative page or analysis tool, that destination needs correct handling. Also limit which data is logged at all. A safely rendered public preview does not justify an uncontrolled complete recording of every submitted message. Operational diagnostics should have a defined purpose and appropriate access.

Keep business data separate from presentation

Do not store suitable text solely as strings already encoded for one particular HTML context. A later export, API or another interface may need a different representation. Mixing output encoding into the business data encourages visibly doubled entities or unsafe decoding at a later point. The apparent convenience can create a much less understandable data path.

Define allowed normalisation at the input boundary and encode at each output location. The same business value can then appear as HTML text, JSON data or a document field according to each destination's rules. That is clearer than maintaining a global field called safe content without saying what it is safe for.

Review these choices when interfaces change. An internal note may later become public. A plain-text field may acquire a rich-content editor. Both change the data contract and require reconsideration of processing and output. Tests written for the old feature cannot establish that the new interpretation path is protected.

Make the output decision visible in review

Destination in the example or a later extensionRequired decision
Name as a headingEncode HTML text and keep the element name fixed
Name in a form controlEncode the attribute value and quote it completely
Website address as a linkApply the URL policy first, then encode the intended href attribute
Data in the JSON script blockGenerate JSON for its HTML embedding and handle failures
Later JavaScript displayPreserve plain text through textContent rather than HTML interpretation
Later database queryUse an appropriate parameterised statement and check access rights

This overview provides a concrete discussion aid beside the implementation. A reviewer identifies the destination of every output and then checks the function actually used there. Revisit the relevant row when the feature changes. Turning a heading into a link changes more than the markup: it introduces a policy for the destination. Reusing the previous helper unchanged would not by itself justify that new operation.

Checklist for a code review

  • Types, required fields, accepted values and length limits are defined per field.
  • Arrays, missing values and invalid encoding are handled deliberately.
  • Bytes, code points and user-perceived characters are distinguished.
  • Normalisation and business validation are understandable separate steps.
  • Only the intended fields enter application processing.
  • HTML text and quoted attributes are encoded at their real output locations.
  • Permitted URL schemes and other URL policies are checked separately.
  • JSON is generated for its actual transfer or embedding context.
  • Later DOM operations preserve the agreed text contract.
  • Authorisation, database access and state-changing actions receive separate review.
  • Boundary values and unusual permitted input have actually been exercised.
  • Documentation promises only the protections the implementation provides.

Begin with one complete data path

For an initial improvement, select one field and trace its entire route: request, parsing, normalisation, business checks, any storage and every output. Record the contract at each boundary. This concrete exercise can reveal problems missed by an isolated search for an escaping function. It also makes the eventual fix easier to review because its scope is explicit.

Extend the tests with actual edge cases from the application. Permitted names containing apostrophes matter just as much as rejected dangerous URL schemes. Good validation protects the business function while respecting allowed data. Good output handling keeps those values in their intended context. Together they create an inspectable process whose protections and remaining limits can be described accurately.

Let’s move forward

What would you like to change?

An idea, a specific challenge or your next step with AI. We’re listening.

Let’s talk