Skip to content

chore(phpstan): raise level to max - #76

Merged
Fredxd merged 2 commits into
AppoloDev:mainfrom
Vondry:chore/phpstan-max-level
Oct 25, 2026
Merged

Fredxd merged 2 commits into
AppoloDev:mainfrom
Vondry:chore/phpstan-max-level

Conversation

@Vondry

@Vondry Vondry commented Oct 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Raises PHPStan from level 8 to max (level 10 on PHPStan 2), one commit per level. Most of the new errors come from values read from the YAML config, the JSON in a record's seo field, and Bolt's untyped APIs. These were passed into string-typed code unchecked, so a value of the wrong type could take the page down with a TypeError. A new ConfigValue helper checks the type first. A wrong type now counts as not set, so the next fallback is used.

A valid config renders exactly as before.

Rules for config and seo field values

Documented in ConfigValue, config/config.yaml and the README:

  • text is used as is, including '0';
  • a number (title: 2024) is used as text;
  • true, false, a list or a map counts as not set, like "".

Behaviour changes

Each one is covered by a test that fails on main.

Level 9 (chore(phpstan): raise level to 9)

  • Override, seo field and default values follow the rules above. Before, a number or a list crashed most getters with a TypeError. true or a list printed 1/Array in others, and empty() skipped '0' in overrides and defaults.
  • Override entries: one that is not a map (e.g. homepage: 'Home') is ignored instead of crashing.
  • Twig record global: one that is not a Content is ignored instead of crashing.
  • _route: a non-text value is treated as no route instead of crashing.
  • Listing route without a content type slug: falls back to the site name instead of crashing.
  • templates.meta: a non-text value keeps the default template. Before, it broke every request, including the admin login.
  • Separator or content type name that is not text: left out, the same as a missing one, instead of printing 1/Array.
  • fields:
    • a single name is accepted (title: headline);
    • names that are not text are skipped;
    • a field without a definition moves on to the next configured name instead of ending the lookup.

Level 10 (chore(phpstan): raise level to max)

  • Bolt's sitename and payoff follow the same rules. A numeric site name used to crash.
  • title_postfix: true or a list means no postfix. true used to append | 1. The admin snippet preview behaves the same.
  • The record's fallback image (from getExtras()) is skipped when it has no url that is text, instead of crashing.
  • SeoInjectorWidget injects nothing when its extension is not a Bolt BaseExtension, instead of an Error.

Unchanged on purpose: without a matched route, an override keyed '' still applies, because PHP has always looked up a null key as ''.

Performance

The front-end path is unchanged. I benchmarked main against this branch with real Twig, a real Request and real config collections:

main this branch
all 7 getters, fresh Seo ~21 µs ~21 µs
metatags() including the Twig render ~34 µs ~34 µs

getExtras(), which builds every link and excerpt of the record, is now called once instead of twice when the image falls back to it.

Verified

  • composer test: 336 tests, OK
  • make cscheck (ECS and PHPStan at max): no errors
  • vendor/bin/rector process --dry-run: no changes
  • End to end in a fresh Bolt 6.1.10 site, switching between main and this branch. Pages: homepage, nl homepage, pages with and without SEO data, an entry, listings, a taxonomy page, search and a 404.
    • Valid config with overrides (route, listing slug, base route with locales): the rendered <head> and all seven values are identical on both. So are the admin edit and new forms (snippet preview data, field mapping, injected assets).
    • A record whose seo field holds wrongly typed values: 500 on main, rendered with the fallbacks above on this branch.
    • A config with wrongly typed values: 500 on every page on main, including the admin login. Every page renders as documented on this branch.
    • title_postfix: true: the only difference from main is the missing | 1.

Narrow untyped values from the YAML config and the seo field JSON through
a new ConfigValue helper instead of passing mixed into string-typed code.

Behaviour changes, each covered by tests and documented in ConfigValue,
config.yaml and the README:
- override, seo field and default values: a number is used as text, '0'
  is a value, and true/false/lists/maps count as not set. Before, a
  number or list caused a TypeError in most getters, true/lists printed
  1/Array in others, and empty() skipped '0' in overrides and defaults.
- an override entry that is not a map, a Twig record global that is not
  a Content, a _route that is not text and a listing route without a
  content type slug are ignored instead of causing a TypeError.
- templates.meta that is not text keeps the default template.
- a title separator or content type name that is not text is left out
  (like a missing one) instead of printing 1/Array.
- fields: a single name (title: headline) is accepted, entries that are
  not text are skipped, and a field definition that is missing moves on
  to the next configured name instead of ending the lookup.
Level max is level 10 on PHPStan 2: values from Bolt's untyped APIs are
narrowed before use. getExtras() is now called once instead of twice.
Override and seo field arrays are typed array<mixed>, since YAML and JSON
keys are not guaranteed to be strings.

Behaviour changes, each covered by tests and documented in code comments,
config.yaml and the README:
- Bolt's sitename and payoff follow the ConfigValue rules: a number is
  used as text (it was a TypeError), and other values that are not text
  count as empty.
- a title_postfix that is not text (true, a list), or an empty postfix
  falling back to a sitename that is not text, means no postfix. true
  used to print 1. The same goes for the admin snippet preview.
- the record extras image is skipped when it has no url that is text, or
  is not a map, instead of causing a TypeError.
- the SEO injector widget injects nothing when its extension is not a
  Bolt BaseExtension, instead of causing an Error.
@Fredxd
Fredxd merged commit 51e14aa into AppoloDev:main Oct 25, 2026
21 of 22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants