Strict types, everywhere
Spec: ISO 32000-2, §7.5.5 ISO 32000-2 §7.5.5 Evidence: Code-backed PHPStan: Level 10, no src baseline
At a glance
Section titled “At a glance”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.
Why this matters
Section titled “Why this matters”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 short version
Section titled “The short version”- 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
ignoreErrorsentries 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: maxand 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.
How NextPDF approaches it
Section titled “How NextPDF approaches it”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:
- Change proposed New or modified engine code.
- Level 10 analysis Strictest PHPStan level over src/, treatPhpDocTypesAsCertain on.
- Zero-error gate No source baseline; unmatched ignores also fail.
- Strict profile level: max; no new ignore entries permitted.
- Redesign, not suppress If it cannot be expressed honestly, the design changes.
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.
What the evidence says
Section titled “What the evidence says”This page is Evidence: Code-backed . The configuration is the evidence:
phpstan.neon.distsetslevel: 10,phpVersion: 80400, analyzessrc, and contains nobaseline:key — there is nophpstan-baseline.neonfor the source analysis.- The same file sets
treatPhpDocTypesAsCertain: trueandreportUnmatchedIgnoredErrors: true, with an inline note that the L10 source analysis is locked at zero errors and any regression must fail CI. - The remaining
ignoreErrorsare each scoped byidentifierand oftenpath, with comments explaining the soft-dependency and reflection-target rationale — they are not a bulk-generated baseline. phpstan-strict.neon.distinherits that config, raises the level tomax, 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 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.
Practical example
Section titled “Practical example”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.
Common misconception
Section titled “Common misconception”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.
Limits and boundaries
Section titled “Limits and boundaries”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:
| 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. |
Related docs
Section titled “Related docs”- The PHP 8.4 foundations — the language features the type system relies on.
- Errors as a feature — what happens to the failures that strict typing surfaces.
- The pipeline model — the architecture this discipline protects.
Glossary
Section titled “Glossary”- 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.