Skip to content

int is dropped from a conditional return type branch typed int|false on PHP 8.3+ #15268

Closed

Description

@westonruter

Bug report

When a function declares a conditional return type and one of its branches is the union int|false, the int member is silently dropped from the type a caller sees, but only when PHPStan itself is executed on PHP 8.3 or newer. Running the same PHPStan version on PHP 8.2 gives the correct result.

The condition itself resolves correctly when the argument type is known. The loss only shows up when the argument is not narrow enough to select a branch, so PHPStan has to combine both branches: the combined type comes back missing int.

Because the resulting type is wrong rather than absent, it produces confident false positives downstream. In our case return.unusedType fires and claims a function never returns int when it plainly does.

The Playground link below does not reproduce this, and that is the clearest evidence for it. The Playground returns the correct bool|int for every analysed phpVersion from 70200 to 80500, which suggests its own runtime is PHP 8.2 or older. Reproducing it needs PHPStan to be run locally on PHP 8.3 or newer.

Reproducer

<?php declare(strict_types=1);

/**
 * @param int|false $priority
 * @return ($priority is int ? bool : int|false)
 */
function leaf($priority = false)
{
    return is_int($priority) ? true : 5;
}

/**
 * @param int|false $priority
 */
function caller($priority): void
{
    \PHPStan\dumpType(leaf($priority)); // bool|int on PHP 8.2, bool on PHP 8.3+
}

Level 10, no bleeding edge, no strict rules, default configuration.

Adding a return makes the consequence visible:

/**
 * @param int|false $priority
 * @return ($priority is int ? bool : int|false)
 */
function wrapper($priority = false)
{
    return leaf($priority);
}
Function wrapper() never returns int so it can be removed from the return type.
    identifier: return.unusedType

wrapper() does return int, since leaf(false) returns 5. Reported from level 4 upward; below that the type is still wrong, only nothing reports it.

Which branch types are affected

Same reproducer throughout, varying only the two branch types, with the parameter int|false and the condition $p is int. PHP 8.3.33, 8.4.25 and 8.5.9 all produce the same results, so they are shown as one column:

branches (true : false) PHP 8.2.33 PHP 8.3+
bool : string|false bool|string bool|string
bool : int|null bool|int|null bool|int|null
bool : int|string bool|int|string bool|int|string
bool : float|false bool|float bool|float
bool : int|false bool|int bool
string : int|false int|string|false string|false
string : int|null int|string|null int|string|null
object : int|false int|object|false object|false

Only branches typed exactly int|false lose a member, and what they lose is always int. The false survives. int|null, int|string, string|false and float|false are all unaffected, as is a plain (non-conditional) @return bool|int.

Environment matrix

PHPStan version makes no difference. The PHP version PHPStan runs on decides it:

PHPStan PHP 8.2.33 PHP 8.3.33 PHP 8.4.25 PHP 8.5.9
2.2.13 bool|int bool bool bool
2.2.14 bool|int bool bool bool
2.2.x-dev@98c2226 bool|int bool bool bool
2.3.x-dev@e8ac517 bool|int bool bool bool

The analysed phpVersion is not the trigger. Setting it to 70400, 80100, 80300, 80400 or 80500 makes no difference in either direction; only the runtime matters.

Ruled out: OPcache (enabled on all four runtimes), JIT (tracing on both 8.2 and 8.3, disabled on 8.4 and 8.5, so it does not track the split), the result cache (verified with cold, isolated tmpDir per run), bleeding edge, strict rules, and treatPhpDocTypesAsCertain.

  • OS: macOS 15.6 (Darwin 24.6.0), arm64
  • PHP builds: Homebrew php@8.2 8.2.33, php@8.3 8.3.33, php@8.4 8.4.25, php 8.5.9

Impact

Found in WordPress core while annotating has_filter(), whose return type is exactly this shape: a bool when called without a callback, the callback's priority as an int otherwise, and false when it is not attached:

 * @phpstan-return (
 *     $callback is false
 *         ? bool
 *         : ( $priority is int
 *             ? bool
 *             : int|false )
 * )

The annotation is correct and resolves correctly at every call site; the false positive lands on the delegating wrappers, because the type they receive from the callee has already lost its int.

A wrong type is worse here than a missing one. Callers that do the natural thing with a priority, such as $priority = has_filter( $hook, $cb ); add_filter( $hook, $cb, $priority + 1 );, are checked against a type that cannot represent what the function returns.

🤖 This report was authored with the assistance of Claude Opus 5 running in Claude Code.

Code snippet that reproduces the problem

Important

This does not produce an error in the version of PHP used in the PHPStan Playground! Try locally in PHP 8.3+.

https://phpstan.org/r/fbdbc4c7-f6cc-4452-ba47-33332f2a5aa2

Expected output

The issue is reported incorrectly. Two things are expected and only the second one holds today.

$priority is int|false, so the condition $priority is int is neither definitively true nor definitively false. Both branches apply, and \PHPStan\dumpType(leaf($priority)) should print the union of them:

Dumped type: bool|int

On PHP 8.3 and newer it prints bool instead.

Following from that, no return.unusedType error is expected for wrapper(), because int is part of what it returns. On PHP 8.3 and newer the error is reported.

Both outputs are already correct when PHPStan runs on PHP 8.2, so no change in behaviour is being asked for beyond making 8.3 and newer agree with it.

Did PHPStan help you today? Did it make you happy in any way?

Type security gives me all the feels!

Activity

  1. westonruter commented on Sep 19, 2026

    @westonruter
    SponsorContributorAuthor

    A correction to the original report, and a narrowing of the trigger that may be a useful lead.

    The report claimed the problem is a branch typed int|false. That is not quite it. The trigger is int|false written in that order, in the else branch specifically. The same union in the then branch is unaffected, and the reversed spelling false|int is unaffected in either position. Since int|false and false|int denote the same type, this looks like an ordering artifact rather than anything to do with the types involved.

    Reproducer

    <?php declare(strict_types = 1);
    
    /**
     * @param int|false $p
     * @return ($p is int ? bool : int|false)
     */
    function else_int_false($p = false) { /** @var mixed $r */ $r = null; return $r; }
    
    /**
     * @param int|false $p
     * @return ($p is int ? bool : false|int)
     */
    function else_false_int($p = false) { /** @var mixed $r */ $r = null; return $r; }
    
    /**
     * @param int|false $p
     * @return ($p is int ? int|false : bool)
     */
    function then_int_false($p = false) { /** @var mixed $r */ $r = null; return $r; }
    
    /** @param int|false $p */
    function probe($p): void {
        \PHPStan\dumpType(else_int_false($p));
        \PHPStan\dumpType(else_false_int($p));
        \PHPStan\dumpType(then_int_false($p));
    }

    All three should print bool|int, since $p is int|false and so selects neither branch.

    PHP running PHPStan else_int_false else_false_int then_int_false
    8.2.33 bool|int bool|int bool|int
    8.3.33 bool bool|int bool|int
    8.4.25 bool bool|int bool|int

    PHPStan 2.2.13, level 10, no bleeding edge, no strict rules. The PHP version dependence in the original report still holds and is unchanged by this.

    Corrected version of the original table

    Re-running the branch-type matrix from the first comment with this in mind: every case that loses a member has int|false in the else position, and it is always the int that is lost. The rows that were reported as unaffected remain unaffected. Additionally:

    branches (then : else) 8.2.33 8.3+
    int|false : bool bool|int bool|int
    false|int : bool bool|int bool|int
    bool : null|int bool|int|null bool|int|null

    So int|null is fine in either order and either position, and int|false is fine everywhere except the else branch in that spelling.

    A guess, offered only as a starting point

    Combining the two branches requires absorbing false into bool, since false is a subtype of bool while int is not, leaving bool|int. Losing int instead has the shape of the wrong member being removed during that absorption, which would explain why it is sensitive to the order the members are written in. Whether the PHP version dependence fits that is not something this end could establish.

    Practical note for anyone hitting this

    Writing the union as false|int avoids it. That is what WordPress core has done: the conditional return types for has_filter() and has_action() originally triggered this and needed an ignoreErrors entry, which reordering the union made unnecessary.

    🤖 This follow-up was authored with the assistance of Claude Opus 5 running in Claude Code. Every type quoted above was produced by running PHPStan locally against the stated PHP builds, and the output is reproduced verbatim.

  2. phpstan-bot commented on Sep 19, 2026

    @phpstan-bot
    Contributor

    Closed via merging phpstan/phpstan-src#6480

  3. github-actions commented on Oct 21, 2026

    @github-actions

    This thread has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs.

  4. locked as resolved and limited conversation to collaborators on Oct 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions