Skip to content

[Bug]: Class constant wildcard in an @extends array shape is resolved in the parent's namespace, not the subclass's imports #116

Closed

Description

@fdaviddpt

Summary

A subclass passes an array shape to a generic parent through @extends. The shape uses a class-constant wildcard on a class the subclass imports (list<Loc::STATUS_ENUM_*>, with use App\Model\Loc;). When the parent's constructor/method checks the template, a valid value is rejected:

App\Cmds\GetLocs::__construct(): Argument $options (template TOptions = array{statuses: list<Loc::STATUS_ENUM_*>})['statuses'][0] must be a valid constant matching Loc::STATUS_ENUM_*, int (1) given

Loc::STATUS_ENUM_ONLINE is 1. The same shape written with the fully qualified name (\App\Model\Loc::STATUS_ENUM_*) is accepted, so the class name looks resolved against the parent's namespace (App\Core) instead of the subclass's imports. The short name in the message (Loc::, not App\Model\Loc::) points the same way.

Close to #93 (relative class name inside an array shape in a generic argument), which was fixed for class names; class-constant references in the same position seem not covered.

Playground

Open in playground — prints FQCN: ok, then the TypeError above on the imported version.

Reproduction

<?php

declare(strict_types=1);

namespace App\Core {
    /**
     * @template TOptions of array<string, mixed>
     */
    abstract class Cmd
    {
        /** @var TOptions */
        protected array $options;

        /** @param TOptions $options */
        public function __construct(array $options)
        {
            $this->options = $options;
        }
    }
}

namespace App\Model {
    final class Loc
    {
        final public const STATUS_ENUM_ONLINE = 1;
        final public const STATUS_ENUM_OFFLINE = 0;
    }
}

namespace App\Cmds {
    use App\Core\Cmd;
    use App\Model\Loc;

    /**
     * @extends Cmd<array{statuses: list<Loc::STATUS_ENUM_*>}>
     */
    final class GetLocs extends Cmd
    {
    }

    /**
     * @extends Cmd<array{statuses: list<\App\Model\Loc::STATUS_ENUM_*>}>
     */
    final class GetLocsFqcn extends Cmd
    {
    }
}

namespace {
    use App\Cmds\GetLocs;
    use App\Cmds\GetLocsFqcn;

    // Control: same shape with the fully qualified class name is accepted.
    new GetLocsFqcn(['statuses' => [1]]);
    echo "FQCN: ok\n";

    // Bug: Loc is imported in App\Cmds, but resolved against App\Core (the parent's namespace).
    new GetLocs(['statuses' => [1]]);
    echo "imported: ok\n";
}

Versions

  • Reproduces on 0.11.4 and on main (fb08b46), PHP 8.2.
  • Locally with separate files per class (PSR-4) the result is identical.

Impact on our side

About 3,000 records in a 26-module run of a real code base: every option shape like @extends CommandGetLocationsBase<array{statuses: list<Location::STATUS_*>, ...}> or list<CommandGetProjectsBase::USER_*> where the constant's class is imported by the subclass.

Activity

  1. self-assigned this
    on Oct 13, 2026
  2. linked a pull request that will close this issueMinor bug fixes #117on Oct 13, 2026
  3. rcalicdan commented on Oct 13, 2026

    @rcalicdan
    Member

    @fdaviddpt Thank you for this bug report.. Should be fixed in v0.11.6

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions