boesing/psalm-plugin-stringf
A Psalm plugin that statically validates printf and sprintf format strings, checking that placeholders match the number and types of arguments passed. Mismatches are caught during analysis instead of at runtime, using Psalm's type inference to verify format specifiers across your codebase.
Install it as a dev dependency alongside Psalm:
composer require --dev boesing/psalm-plugin-stringf
vendor/bin/psalm-plugin enable boesing/psalm-plugin-stringf
The enable command writes the registration into psalm.xml. If you configure manually, add the plugin class:
<plugins>
<pluginClass class="Boesing\PsalmPluginStringf\Plugin"/>
</plugins>
Then clear Psalm's cache and run against a single file so the new diagnostics are visible immediately:
vendor/bin/psalm --no-cache src/Formatting/MessageFormatter.php
Where to look first: any file that builds strings with sprintf/printf, or parses with sscanf/fscanf. Logging, i18n, CLI output, and fixed-width parsing code are the highest-yield targets.
First use case: a message formatter with a missing or mistyped placeholder.
// Before: Psalm says nothing, runtime emits "ValueError: too few arguments"
return sprintf('User %s logged in from %s at %d', $name, $ip);
Once the plugin is enabled, the arity mismatch and the type mismatch on %d surface as Psalm issues at the call site, in the same report you already run in CI.
Keep format strings inline and literal. The plugin resolves string literals and constants; that is the whole basis of its analysis. The most reliable pattern is a literal first argument with a fixed argument list:
sprintf('Processed %d of %d records in %.2fs', $done, $total, $elapsed);
Promote reusable formats to class constants rather than concatenating at the call site. Constants stay resolvable, concatenation usually does not:
final class Formats
{
public const LOGIN = 'User %s logged in from %s at %d';
}
sprintf(Formats::LOGIN, $name, $ip, $timestamp);
Use it as a CI gate, not an editor-only nicety. Because the plugin only adds diagnostics to existing rules, it drops into an existing composer psalm / vendor/bin/psalm --threads=4 step with no new tooling. Adopt it behind a baseline first (see below) so the PR is about the plugin, not about hundreds of pre-existing format strings.
Pair printf and sprintf checks with sscanf/fscanf parsing code. If you parse log lines or fixed-format input, the same plugin validates the parse pattern against the assignment targets:
[$level, $message] = sscanf($line, '[%s] %[^$]');
This is where the plugin earns its keep on parsers: a format specifier that no longer matches the destructured variables is caught at analysis time instead of producing null fields in production.
Wrap translation deliberately, not accidentally. A helper like t('Hello %s', $name) hides the sprintf call, so the plugin can no longer see the format string at the call site. Two workable layouts:
sprintf at the call site with a literal format and let a translator return the already-resolved format only where static checking is not worth it: sprintf($translator->get('login'), $name).When adopting on a legacy codebase, baseline in two passes:
vendor/bin/psalm --set-baseline=psalm-baseline.xml
vendor/bin/psalm
Then delete baseline entries file-by-file as you fix real bugs. Do not leave the plugin unbaselined on day one — it will drown out unrelated findings.
sprintf('%s ' . $suffix, $x), or returned from a function silently gets no analysis. It is not an error, so the absence of issues is not proof of correctness.sprintf($format, ...$args) cannot be arity-checked. The same goes for call_user_func('sprintf', ...) and similar indirection.sprintf is loose; the plugin is not. PHP happily coerces "42" into %d and an int into %s. Expect the plugin to flag some calls that "work fine at runtime", especially %s with non-string scalars or Stringable objects. Cast at the call site ((string) $value) or add a narrow @psalm-suppress rather than loosening your config globally.%1$s / %2$d reorder arguments; verify your specific call against the plugin after enabling rather than assuming one-to-one positional matching. %% and width/precision flags (%05.2f) are parsed, but nested/combined specifiers are the first place to look when a real mismatch is missed.--no-cache and confirm the plugin actually loaded (Psalm warns about unresolved plugin classes).psalm/psalm to a version you have verified, or run the plugin's own tests against the Psalm version in your lockfile before rolling out. Do not assume it works against the newest Psalm major.<plugins> and re-test.__(), _e(), Log::info('... %s', …) and similar wrappers are ordinary functions. The plugin only knows the four native functions. If you need the same checks for your own helper, you will have to write a small Psalm plugin (a function-call analyzer or return-type provider) — this package exposes no user-facing extension point or config.NOASSERTION. If you have compliance review, confirm the actual licence from the repository's LICENSE file before shipping it into a locked-down CI image.How can I help you explore Laravel packages today?