Skip to content

Strict types, everywhere

Spec: ISO 32000-2, §7.5.5 Evidence: Code-backed PHPStan: Level 10, no src baseline

NextPDF runs PHPStan at Level 10 over the engine source with no suppression baseline. This page explains why “no baseline” is a design decision rather than a tooling detail, and what that strictness actually buys a pipeline whose job is to avoid mishandling data silently.

In most applications, strict typing is hygiene. In a PDF engine, it is closer to a correctness mechanism. The format is unforgiving. A reader is expected to locate content by reading the file from its end, through the trailer and the cross-reference table, so a writer’s byte offsets must be exact. Consider a type that quietly widens to mixed, an int that silently becomes a string, or a nullable that is dereferenced unchecked. Any of these can produce a file that opens fine in one viewer and fails validation in another, weeks later, with no stack trace pointing back at the cause.

The expensive failures in this domain are the silent ones. Strict typing plus a strict analyzer is how the engine converts a class of silent runtime failures into loud build-time ones.

  • The engine source is analyzed at PHPStan Level 10 — the strictest level — verified in phpstan.neon.dist.
  • There is no source suppression baseline. The configuration locks the source analysis at zero errors. A regression fails the build rather than being absorbed into a growing ignore file.
  • The few ignoreErrors entries that exist are narrow, identifier- and path-scoped, and individually justified in the config (cross-package soft-dependency boundaries and reflection-target test seams) — not a bulk baseline.
  • A separate strict profile runs level: max and forbids any new ignore entries, so new code is held to an even tighter standard.
  • The intended effect is design pressure: code that cannot be expressed type-honestly does not pass, so it gets redesigned instead of suppressed.

The difference between “we use a strict analyzer” and “we use a strict analyzer with no baseline” is the whole point, so it is worth being precise.

A baseline records every existing violation and tells the analyzer to ignore exactly those. It is a pragmatic way to adopt static analysis on a legacy codebase, but it has a cost. The baseline becomes a quiet ledger of debt that the type system has agreed not to look at. New violations of the same kind can slip in next to existing ones. The analyzer’s promise weakens from “this code is type-clean” to “this code is no worse than it was.”

NextPDF does not make that trade for the engine source. The configuration fixes the source analysis at zero errors and turns on reportUnmatchedIgnoredErrors, so even a stale suppression — one that no longer matches anything — fails the build. The narrow ignores that remain are scoped to a specific error identifier and file. Each one carries an inline explanation of why the boundary is intentional (for example, core programming to a Pro/Enterprise interface it deliberately does not depend on concretely). A reviewer can read each one and judge it. There is no opaque list to lose track of.

The flow that keeps this honest:

  1. Change proposed New or modified engine code.
  2. Level 10 analysis Strictest PHPStan level over src/, treatPhpDocTypesAsCertain on.
  3. Zero-error gate No source baseline; unmatched ignores also fail.
  4. Strict profile level: max; no new ignore entries permitted.
  5. Redesign, not suppress If it cannot be expressed honestly, the design changes.
How a change reaches the engine source: a type-dishonest change cannot pass the gate, so it is redesigned rather than suppressed.

treatPhpDocTypesAsCertain is part of this. PHPDoc annotations are treated as ground truth, so a @param list<T> or @return non-empty-string is not a comment the analyzer politely ignores. It is a checked promise. Annotation and runtime type are forced to agree.

This page is Evidence: Code-backed . The configuration is the evidence:

  • phpstan.neon.dist sets level: 10, phpVersion: 80400, analyzes src, and contains no baseline: key — there is no phpstan-baseline.neon for the source analysis.
  • The same file sets treatPhpDocTypesAsCertain: true and reportUnmatchedIgnoredErrors: true, with an inline note that the L10 source analysis is locked at zero errors and any regression must fail CI.
  • The remaining ignoreErrors are each scoped by identifier and often path, with comments explaining the soft-dependency and reflection-target rationale — they are not a bulk-generated baseline.
  • phpstan-strict.neon.dist inherits that config, raises the level to max, and freezes the ignore list so no new entry may be added under the strict profile.

The standards angle is direct. The engine must produce files a reader can navigate from the trailer and cross-reference table per Spec: ISO 32000-2, §7.5.5 . Exact byte offsets are a type problem before they are a serialization problem. An offset is an integer that must never silently become anything else. A pipeline that is type-clean at Level 10 has already removed most of the ways that arithmetic can go quietly wrong.

Strict typing is most visible where a domain rule is encoded as a type instead of a runtime check. The conformance discriminator answers spec-level questions with exhaustive match, so an unhandled case is a type error, not a wrong PDF:

declare(strict_types=1);
enum ConformanceMode: string
{
case Plain = 'plain';
case PdfUa2 = 'pdfua2';
case PdfA4 = 'pdfa4';
/** @return 2|3|4|null */
public function pdfaPart(): ?int
{
return match ($this) {
self::PdfA4 => 4,
default => null,
};
}
}

The @return 2|3|4|null is not documentation. Under treatPhpDocTypesAsCertain, it is checked. A caller that assumes the result is always an int is told so at analysis time, before a single byte of a non-conforming PDF/A part number is ever written.

The trap is reading “no baseline” as “the code happens to have no violations.” That is backwards. The absence of a baseline is the cause, not a lucky outcome. Because there is nowhere to park a violation, code that would produce one has to be written differently. Level 10 with no source baseline is a constraint that shapes the design, not a report card that describes it after the fact.

A second misconception: that the handful of ignoreErrors entries are a baseline by another name. They are not. A baseline is bulk-generated and opaque. These are individually written, identifier-scoped, explained, and guarded by reportUnmatchedIgnoredErrors so they cannot rot unnoticed.

This page is about the engine source analysis. The test suite is analyzed under a separate, deliberately distinct scope and configuration; “no baseline” here is a statement about src/, not a claim that every auxiliary analysis in the repository is baseline-free. PHPStan proves type soundness, not behavioral correctness. It does not replace the testing pyramid, only removes a category of failure the tests would otherwise have to chase. The exact level, flags, and ignore set are accurate as of this page’s review date. The authoritative source is always phpstan.neon.dist and phpstan-strict.neon.dist in the core repository.

Edition does not change this discipline. Every edition is built from the same Level 10 source:

Level 10 source analysis — edition availability
Edition Availability
Core Core source is analysed at Level 10 with no source baseline.
Pro Pro is built on the same Level 10 source discipline.
Enterprise Enterprise is built on the same Level 10 source discipline.
  • PHPStan Level 10 — the strictest analysis level, treating untyped and loosely typed values as errors rather than warnings.
  • Baseline — a generated record of existing violations the analyzer is told to ignore. NextPDF uses none for the engine source.
  • treatPhpDocTypesAsCertain — a PHPStan setting that treats PHPDoc type annotations as checked facts, not advisory comments.
  • reportUnmatchedIgnoredErrors — a setting that fails the build when an ignore entry no longer matches anything, preventing stale suppressions.
  • Design pressure — the effect of a constraint that forces code to be written a certain way, as opposed to a check that only measures it.