Skip to content

Validate form field values and flatten with interactive state preservation

A production form pipeline rarely flattens raw input. First, you validate each value. Then you decide which fields become permanent and which stay editable. NextPDF core gives you two building blocks for this work: the HasFormFields authoring trait, which writes a field’s value into the document as you create it, and flattenForms(), which bakes every field into static page graphics and drops the interactive form.

This recipe connects those two blocks with an application-layer validation step that core deliberately leaves to you. You will:

  • Validate a value map against per-field rules before you author any field, so an invalid value never reaches the document.
  • Build one validated dataset, then emit it twice — once flattened (a locked, read-only copy) and once interactive (an editable copy) — so the same field state carries across both outputs.

Prerequisites: a working NextPDF core install (composer require nextpdf/core), plus a read-through of Build and pre-fill a PDF form and Flatten form fields, whose field-authoring and flattening mechanics this recipe combines.

Scope boundary. Core’s flattenForms() is a whole-document operation: it flattens every field or none. The core form API has no public per-field flatten switch and no built-in value validator. So “flatten some, keep others editable” happens at the application layer: validate once, then render the same validated dataset into two documents. This recipe documents that pattern; it does not invent a per-field core method.

Terminal window
composer require nextpdf/core

You do not need an extra extension. The form-authoring trait and the flattener both ship in core.

An Acrobat form (AcroForm) field stores its current value in the V entry of its field dictionary. flattenForms() reads each field’s V value and renders it into the owning page’s content stream — text fields become BT ... Tj ... ET text, checkboxes and radio buttons become drawn paths, and choice fields render their selected item. Then it removes the /AcroForm catalog entry. The result is a non-interactive form: a static representation of the fields that displays identically in any reader, with no form-filling capability required (ISO 32000-2 12.7).

Two facts shape the production pattern:

  1. Core does not validate field values. Each authoring method (textField(), comboBox(), checkBox(), and the rest) writes whatever value you pass straight into V. Email format, allowed-option membership, and required-field presence are application concerns. Validate before you author, and fail fast on a violation instead of emitting a document with a bad value baked in.

  2. Flattening is irreversible and whole-document. After you call flattenForms() and save(), the fields are static graphics. To keep an editable copy too, you do not un-flatten — you render the validated dataset a second time without calling flattenForms(). Both copies start from the same validated values, so the locked copy and the editable copy carry identical field state.

The reproducibility profile is structural: each document carries a trailer /ID array that a post-pass normalises before two runs are compared.

NextPDF\Core\Document (via NextPDF\Core\Concerns\HasFormFields):

  • textField(string $name, float $x, float $y, float $w, float $h, string $default = '', array $options = []): static — author a text field with its value in default.
  • comboBox(string $name, float $x, float $y, float $w, float $h, array $items, string $selected = ''): static — author a dropdown with its selected item in selected.
  • checkBox(string $name, float $x, float $y, float $size, bool $checked = false): static — author a checkbox with its state in checked.
  • flattenForms(): static — bake every field’s value into static page content and drop the AcroForm. A no-op when no fields exist. Internally delegates to NextPDF\Form\FormFlattener.

NextPDF\Core\Concerns\HasOutput:

  • save(string $path): void — build and write the PDF. Throws NextPDF\Exception\InvalidConfigException when the output path is a stream wrapper, contains a null byte, or names a non-existent parent directory.

The validator in the samples below is application code you own, not a core symbol. Core has no value-validation API, which is why the validation step is explicit here.

This minimal flow validates a value map, authors three fields from it, and then flattens and saves one locked copy.

<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\Document;
/** @var array<string, string> $input Untrusted value map (e.g. from a request). */
$input = [
'full_name' => 'Ada Lovelace',
'email' => '[email protected]',
'country' => 'Taiwan',
];
$allowedCountries = ['United Kingdom', 'Taiwan', 'Japan'];
// Validate before authoring: core writes whatever you pass, so reject early.
if (trim($input['full_name']) === '') {
throw new InvalidArgumentException('full_name must not be empty.');
}
if (filter_var($input['email'], FILTER_VALIDATE_EMAIL) === false) {
throw new InvalidArgumentException('email is not a valid address.');
}
if (!in_array($input['country'], $allowedCountries, true)) {
throw new InvalidArgumentException('country is not an allowed option.');
}
$doc = Document::createStandalone();
$doc->setTitle('Validated Form (locked copy)');
$doc->addPage();
$doc->textField(name: 'full_name', x: 20, y: 30, w: 90, h: 8, default: $input['full_name']);
$doc->textField(name: 'email', x: 20, y: 45, w: 90, h: 8, default: $input['email']);
$doc->comboBox(
name: 'country',
x: 20, y: 60, w: 90, h: 8,
items: $allowedCountries,
selected: $input['country'],
);
// Whole-document flatten: every field becomes static graphics.
$doc->flattenForms();
$doc->save(__DIR__ . '/registration-locked.pdf');
echo "Wrote registration-locked.pdf\n";

The production flow separates validation from rendering. A typed FieldRuleSet validates the value map once and returns a validated dataset. One renderForm() helper authors the fields and runs twice — with flattening for the locked copy, and without it for the editable copy. Both copies come from the same validated values, so interactive state carries across them. The harness reads output paths from NEXTPDF_COOKBOOK_LOCKED_OUTPUT and NEXTPDF_COOKBOOK_EDITABLE_OUTPUT.

<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\Document;
use NextPDF\Exception\InvalidConfigException;
/**
* A single validated form record. Immutable: once constructed, every value has
* already passed the rule set, so the rendering step cannot re-introduce a bad
* value. This is the "validate once, render many" boundary.
*/
final readonly class ValidatedRegistration
{
/**
* @param non-empty-string $fullName
* @param non-empty-string $email
* @param non-empty-string $country
*/
public function __construct(
public string $fullName,
public string $email,
public string $country,
public bool $newsletter,
) {}
}
/**
* Application-layer field validation. Core performs no value validation, so the
* rules live here. Each method throws on the first violation; the caller maps
* the exception to an HTTP 422 or a user-facing message.
*/
final class FieldRuleSet
{
/** @var list<non-empty-string> */
private const array ALLOWED_COUNTRIES = ['United Kingdom', 'Taiwan', 'Japan', 'Germany'];
/**
* @param array<string, string|bool> $input Untrusted value map.
*
* @throws InvalidArgumentException When any field fails its rule.
*/
public function validate(array $input): ValidatedRegistration
{
$fullName = $this->requireNonEmpty($input, 'full_name');
$email = $this->requireEmail($input, 'email');
$country = $this->requireAllowed($input, 'country', self::ALLOWED_COUNTRIES);
$newsletter = $input['newsletter'] ?? false;
if (!is_bool($newsletter)) {
throw new InvalidArgumentException('newsletter must be a boolean.');
}
return new ValidatedRegistration(
fullName: $fullName,
email: $email,
country: $country,
newsletter: $newsletter,
);
}
/**
* @param array<string, string|bool> $input
*
* @return non-empty-string
*
* @throws InvalidArgumentException
*/
private function requireNonEmpty(array $input, string $key): string
{
$value = $input[$key] ?? '';
if (!is_string($value) || trim($value) === '') {
throw new InvalidArgumentException(sprintf('%s must not be empty.', $key));
}
return $value;
}
/**
* @param array<string, string|bool> $input
*
* @return non-empty-string
*
* @throws InvalidArgumentException
*/
private function requireEmail(array $input, string $key): string
{
$value = $input[$key] ?? '';
if (!is_string($value) || $value === '' || filter_var($value, FILTER_VALIDATE_EMAIL) === false) {
throw new InvalidArgumentException(sprintf('%s is not a valid email address.', $key));
}
return $value;
}
/**
* @param array<string, string|bool> $input
* @param list<non-empty-string> $allowed
*
* @return non-empty-string
*
* @throws InvalidArgumentException
*/
private function requireAllowed(array $input, string $key, array $allowed): string
{
$value = $input[$key] ?? '';
if (!is_string($value) || !in_array($value, $allowed, true)) {
throw new InvalidArgumentException(sprintf('%s is not an allowed option.', $key));
}
return $value;
}
/** @return list<non-empty-string> */
public function allowedCountries(): array
{
return self::ALLOWED_COUNTRIES;
}
}
/**
* Author the same field layout from one validated record. When $flatten is
* true, every field is baked into static page content and the AcroForm is
* dropped; when false, the fields stay interactive and editable. Both paths
* start from identical values, preserving the form's state across copies.
*/
function renderForm(ValidatedRegistration $record, FieldRuleSet $rules, bool $flatten): Document
{
$doc = Document::createStandalone();
$doc->setTitle($flatten ? 'Customer Registration (locked)' : 'Customer Registration (editable)');
$doc->addPage();
$doc->setFont('helvetica', 'B', 18);
$doc->cell(0, 12, 'Customer Registration', newLine: true);
$doc->ln(4);
$leftMargin = 15.0;
$fieldX = 70.0;
$fieldW = 120.0;
$fieldH = 8.0;
$rowSpacing = 12.0;
$y = 40.0;
$doc->setFont('helvetica', '', 10);
$doc->setXY($leftMargin, $y);
$doc->cell(50, $fieldH, 'Full Name:');
$doc->textField(name: 'full_name', x: $fieldX, y: $y, w: $fieldW, h: $fieldH, default: $record->fullName);
$y += $rowSpacing;
$doc->setXY($leftMargin, $y);
$doc->cell(50, $fieldH, 'Email:');
$doc->textField(name: 'email', x: $fieldX, y: $y, w: $fieldW, h: $fieldH, default: $record->email);
$y += $rowSpacing;
$doc->setXY($leftMargin, $y);
$doc->cell(50, $fieldH, 'Country:');
$doc->comboBox(
name: 'country',
x: $fieldX,
y: $y,
w: $fieldW,
h: $fieldH,
items: $rules->allowedCountries(),
selected: $record->country,
);
$y += $rowSpacing;
$doc->setXY($leftMargin, $y);
$doc->cell(50, $fieldH, 'Newsletter:');
$doc->checkBox(name: 'newsletter', x: $fieldX, y: $y, size: 5, checked: $record->newsletter);
if ($flatten) {
// Whole-document flatten: locked, read-only copy.
$doc->flattenForms();
}
return $doc;
}
/** @var array<string, string|bool> $input Untrusted value map (request payload). */
$input = [
'full_name' => 'Ada Lovelace',
'email' => '[email protected]',
'country' => 'Taiwan',
'newsletter' => true,
];
$rules = new FieldRuleSet();
try {
// Validate once. A violation aborts before any document is built.
$record = $rules->validate($input);
// Render the validated dataset twice: locked, then editable.
$locked = renderForm($record, $rules, flatten: true);
$editable = renderForm($record, $rules, flatten: false);
$lockedPath = getenv('NEXTPDF_COOKBOOK_LOCKED_OUTPUT')
?: __DIR__ . '/registration-locked.pdf';
$editablePath = getenv('NEXTPDF_COOKBOOK_EDITABLE_OUTPUT')
?: __DIR__ . '/registration-editable.pdf';
$locked->save($lockedPath);
$editable->save($editablePath);
echo "Wrote locked and editable registration copies\n";
} catch (InvalidArgumentException $e) {
// Validation failure: a bad value never reached the document.
fwrite(STDERR, 'Form validation failed: ' . $e->getMessage() . "\n");
exit(1);
} catch (InvalidConfigException $e) {
// Output failure: bad path, missing directory, or stream wrapper.
fwrite(STDERR, sprintf(
'PDF save failed for key "%s": %s' . "\n",
$e->getConfigKey(),
$e->getMessage(),
));
exit(1);
}

Expected output:

Wrote locked and editable registration copies

The locked copy opens with no interactive fields — the values are static graphics. The editable copy opens with the same values pre-filled, and every field remains editable. Both reflect the single validated dataset.

  • Validate before you author, not after. The authoring methods write your value into V verbatim. No core hook rejects a malformed value at save() time, so a value that skipped validation is baked into the flattened copy with no recovery path.
  • flattenForms() is all-or-nothing. It flattens every field on the document. To keep some fields editable, render a second document without the flatten call, as the production sample does — do not expect a per-field switch in the core API.
  • Call order. Author every field first, then call flattenForms(), then save(). Calling flattenForms() with no fields is a safe no-op; calling it after save() has no effect on the bytes already written.
  • Field names must be unique. Two fields that share a name become one logical field with a shared value in conforming readers. Validate names for uniqueness when they are data-driven.
  • Checkbox truthiness on flatten. A flattened checkbox draws its checkmark when its value is Yes, On, 1, or true; an empty or Off value draws only the box. Pass a real boolean to checkBox() so the rendered state matches your validated value.
  • Signature fields are never flattened. A /Sig field’s surface is the appearance produced from its signature payload, not a re-renderable value, so the flattener skips it. Flatten before you sign, never after.
  • The editable copy is still editable. Preserving state in the editable copy means the recipient can change it. Treat the locked copy as the authoritative record and the editable copy as a working draft.

Validation is linear in the field count and runs once per dataset. Each render authors one widget annotation, plus an appearance, per field. The flatten step appends a bounded content block per field. Rendering the dataset twice roughly doubles the per-document cost, which still stays well inside a 1500 ms / 64 MB budget for forms of a few hundred fields. If you need only one output, render once and skip the second pass.

  • Validate untrusted input at the boundary. Email format, allowed-option membership, and required-field presence are enforced in application code because core does not enforce them. Escape or normalise any value derived from untrusted input before it reaches a field, because core writes it verbatim into the document.
  • Flattening is not access control. A flattened value is non-editable in a normal reader, but it stays visible in the page content and extractable with any text tool. Do not treat flattening as redaction or as protection for sensitive values.
  • The editable copy carries the same data. Distribute it only to parties allowed to see and change those values. When the content is sensitive, combine either copy with Encrypt with permissions, and note the reader-cooperative caveat described there: permission bits do not enforce read restrictions.
  • Fail closed. The production sample exits non-zero on a validation or output failure instead of writing a partial or invalid document. Never swallow these exceptions.
StatementSpecClausereference_id
A flattened form is a non-interactive (static) representation of the fields.ISO 32000-212.7

NextPDF produces the static structure described by the cited clause; it does not assert blanket ISO 32000-2 conformance. The validation rules in this recipe are application policy, not a conformance requirement of the standard.