Skip to content

Fix phpstan/phpstan#13806: PHPStan assumes string conversions cannot throw exceptions#5391

Merged
VincentLanglet merged 9 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-cvn0rpz
Apr 4, 2026
Merged

Fix phpstan/phpstan#13806: PHPStan assumes string conversions cannot throw exceptions#5391
VincentLanglet merged 9 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-cvn0rpz

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

@phpstan-bot phpstan-bot commented Apr 3, 2026

Summary

PHPStan incorrectly reported "Dead catch - Throwable is never thrown in the try block" for try/catch blocks wrapping string casts (string) $variable. Since PHP 7.4, __toString() can throw exceptions, so string casts on objects with __toString() should create throw points.

Changes

  • Modified src/Analyser/ExprHandler/CastStringHandler.php to create throw points when a __toString() method is detected on the cast expression's type
  • Added implicitThrows constructor parameter (autowired from %exceptions.implicitThrows% config)
  • Creates explicit throw points when __toString() has a declared throw type or returns never
  • Creates implicit throw points when implicitThrows is enabled and no explicit throw type is declared
  • Added regression test in tests/PHPStan/Rules/Exceptions/data/bug-13806.php

Root cause

CastStringHandler already detected __toString() methods to track impurity (side effects), but it never created throw points for the potential exceptions. This meant the try/catch dead-code analysis saw no throw points from string casts and incorrectly reported catch blocks as dead code.

The fix mirrors the throw point logic from MethodCallHandler::getMethodThrowPoint(): check for explicit throw types first, then fall back to implicit throws if configured.

Test

Added testBug13806 in CatchWithUnthrownExceptionRuleTest with a test case that casts a Stringable|string variable to string inside a try block with a catch for \Throwable. The test expects no errors (the catch should not be reported as dead).

Fixes phpstan/phpstan#13806
Closes #4555

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be dependant on phpVersion

public function throwsOnStringCast(): bool
	{
		return $this->versionId >= 70400;
	}

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Both background tasks (static analysis and tests) also completed successfully. All checks are green and the changes have been pushed.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add test when casting a non object, like int and ensure the useless throw is reported

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Background make tests also completed successfully. All checks passed and the commit has been pushed.

}

class MyString {
public function __toString(): never {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove the native return type to avoid lint failure

Bug13806\MyString::__toString(): Return type must be string when declared

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All checks passed:

  • make phpstan: No errors
  • make tests: 11722 tests, 78963 assertions, all OK

The commit has been pushed to the branch.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add test with

  • A @throws Exception
  • A @throws void

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Background make phpstan also completed successfully. All tasks are done — the commit with the @throws Exception and @throws void test cases has been pushed.

@VincentLanglet VincentLanglet requested a review from staabm April 3, 2026 14:56
if ($this->phpVersion->throwsOnStringCast()) {
$throwType = $toStringMethod->getThrowType();
if ($throwType === null) {
$returnType = $toStringMethod->getVariants()[0]->getReturnType();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this use getOnlyVariant() instead of getVariants()[0]?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Background make tests also completed successfully. All done — both checks passed and the commit addressing staabm's review has been pushed.

private InitializerExprTypeResolver $initializerExprTypeResolver,
private PhpVersion $phpVersion,
#[AutowiredParameter(ref: '%exceptions.implicitThrows%')]
private bool $implicitThrows,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need separate tests for this bool beeing true/false?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Background make tests also completed successfully. All tasks are done — both review comments have been addressed and pushed.

@staabm
Copy link
Copy Markdown
Contributor

staabm commented Apr 3, 2026

ondrej mentioned getMethodThrowPoint in https://github.com/phpstan/phpstan-src/pull/4555/files#r2786613055
should it share logic?

@VincentLanglet
Copy link
Copy Markdown
Contributor

ondrej mentioned getMethodThrowPoint in #4555 (files) should it share logic?

Indeed cause someone could write a dynamic return type extension for the toStringMethod.

}

if ($this->phpVersion->throwsOnStringCast()) {
$throwType = $toStringMethod->getThrowType();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code is similar to MethodCallHandler::getMethodThrowPoint and also have the benefit of supporting the dynamicReturnTypeExtension.

You should look for a refactoring.
Maybe introduce a MethodCallHelper similar to the MethodCallReturnTypeHelper or just rename it MethodCallHelper.

It could also maybe be refactored with StaticCallHandler::getStaticMethodThrowPoint

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All background tasks have completed successfully. The refactoring is done and pushed.

staabm and others added 7 commits April 3, 2026 18:20
… __toString()

- Added throw point creation in CastStringHandler when __toString() method exists
- Respects explicit throw types from __toString() and implicit throws configuration
- New regression test in tests/PHPStan/Rules/Exceptions/data/bug-13806.php
- The root cause was that CastStringHandler tracked impurity but not throw points for __toString()
__toString() can only throw exceptions since PHP 7.4. Add
PhpVersion::throwsOnStringCast() and gate the throw point
creation in CastStringHandler on it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Ensures that casting a non-object type (int) to string correctly
reports the catch block as dead code, since no __toString() method
is involved and no exception can be thrown.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The native `never` return type on `__toString()` causes a lint error
since PHP requires `__toString()` to declare `string` as its return type.
Use a `@return never` PHPDoc instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
__toString() always has exactly one variant, so getOnlyVariant() is the
correct API to use here.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Address review feedback:
- Use getOnlyVariant() instead of getVariants()[0] for __toString()
- Add test in AbilityToDisableImplicitThrowsTest to verify string cast
  throw points behave correctly when implicitThrows is disabled

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@VincentLanglet VincentLanglet force-pushed the create-pull-request/patch-cvn0rpz branch 2 times, most recently from 32e6017 to 9fac56e Compare April 3, 2026 16:23
Refactor duplicate throw point resolution from CastStringHandler,
MethodCallHandler, and StaticCallHandler into a shared helper class.
This also fixes StaticCallHandler missing the never-return-type
fallback to Throwable that MethodCallHandler already had.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@VincentLanglet VincentLanglet force-pushed the create-pull-request/patch-cvn0rpz branch from 9fac56e to d898b9a Compare April 3, 2026 16:24
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix

FILE: src/Analyser/ExprHandler/CastStringHandler.php
--------------------------------------------------------------------------------
FOUND 1 ERROR AFFECTING 1 LINE
--------------------------------------------------------------------------------
 67 | ERROR | [x] Multi-line function calls must have a trailing comma after
    |       |     the last parameter.
    |       |     (SlevomatCodingStandard.Functions.RequireTrailingCommaInCall.MissingTrailingComma)
--------------------------------------------------------------------------------
PHPCBF CAN FIX THE 1 MARKED SNIFF VIOLATIONS AUTOMATICALLY
--------------------------------------------------------------------------------


FILE: src/Analyser/ExprHandler/Helper/MethodThrowPointHelper.php
--------------------------------------------------------------------------------
FOUND 2 ERRORS AFFECTING 2 LINES
--------------------------------------------------------------------------------
 69 | ERROR | [x] Function in_array() should not be referenced via a fallback
    |       |     global name, but via a use statement.
    |       |     (SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly.ReferenceViaFallbackGlobalName)
 70 | ERROR | [x] Function in_array() should not be referenced via a fallback
    |       |     global name, but via a use statement.
    |       |     (SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly.ReferenceViaFallbackGlobalName)
--------------------------------------------------------------------------------
PHPCBF CAN FIX THE 2 MARKED SNIFF VIOLATIONS AUTOMATICALLY
--------------------------------------------------------------------------------

…intHelper

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Both background tasks confirmed complete. All changes are already pushed.

@VincentLanglet VincentLanglet requested a review from staabm April 3, 2026 21:50
Copy link
Copy Markdown
Contributor

@staabm staabm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice

@VincentLanglet VincentLanglet merged commit c7c7d7a into phpstan:2.1.x Apr 4, 2026
654 of 656 checks passed
@VincentLanglet VincentLanglet deleted the create-pull-request/patch-cvn0rpz branch April 4, 2026 08:15
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.

3 participants