diff --git a/CHANGELOG.md b/CHANGELOG.md
index 335239e..aa24e48 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,8 @@
- fix `average()` so non-numeric values no longer error on modern PHP versions
- make `changeKeyCase()` Unicode case conversion deterministic across PHP 8.0–8.5
- strengthen native property type checks, array-shape contracts, and regression coverage across Json mapper and collection helpers
+- add PHPStan + runtime coverage for `meta()` with array-shape-backed models and document the recommended usage in the README
+- stabilize the full PHPUnit / PHPStan CI matrix across PHP 8.0–8.5 for both lowest and current dependency sets
- remove stale PHP 8-only compatibility branches, clean up PHPStan ignores, and refresh the PHP 8.0+ docs/CI matrix
### 7.10.0 (2026-04-24)
diff --git a/README.md b/README.md
index a490fd3..55c48cd 100644
--- a/README.md
+++ b/README.md
@@ -118,7 +118,7 @@ $arrayy->Lars->lastname; // 'Müller'
## PhpDoc array-shape / property checking
-The library offers type checking for phpdoc array-shape annotations, legacy `@property` phpdoc-class-comments, and native declared properties. Prefer the array-shape form because it can reuse the `Arrayy` template for IDE autocompletion and static-analysis support. When you want PHPStan to check reads precisely, prefer array-like access with literal keys (for example `$user['lastName']`) on these array-shape-based models. Do not combine array-shape annotations and `@property` tags on the same model.
+The library offers type checking for phpdoc array-shape annotations, legacy `@property` phpdoc-class-comments, and native declared properties. Prefer the array-shape form because it can reuse the `Arrayy` template for IDE autocompletion and static-analysis support. `meta()` is also understood by PHPStan, so `meta()`-derived keys such as `$userMeta->city` and `$cityMeta->name` keep precise literal-string information during static analysis. When you want PHPStan to check reads precisely, prefer array-like access with literal keys (for example `$user['lastName']`) or narrowed `meta()` keys on these array-shape-based models. Do not combine array-shape annotations and `@property` tags on the same model.
```php
/**
diff --git a/build/docs/base.md b/build/docs/base.md
index b182a06..793c9ed 100644
--- a/build/docs/base.md
+++ b/build/docs/base.md
@@ -117,7 +117,7 @@ $arrayy->Lars->lastname; // 'Müller'
## PhpDoc array-shape / property checking
-The library offers type checking for phpdoc array-shape annotations, legacy `@property` phpdoc-class-comments, and native declared properties. Prefer the array-shape form because it can reuse the `Arrayy` template for IDE autocompletion and static-analysis support. When you want PHPStan to check reads precisely, prefer array-like access with literal keys (for example `$user['lastName']`) on these array-shape-based models. Do not combine array-shape annotations and `@property` tags on the same model.
+The library offers type checking for phpdoc array-shape annotations, legacy `@property` phpdoc-class-comments, and native declared properties. Prefer the array-shape form because it can reuse the `Arrayy` template for IDE autocompletion and static-analysis support. `meta()` is also understood by PHPStan, so `meta()`-derived keys such as `$userMeta->city` and `$cityMeta->name` keep precise literal-string information during static analysis. When you want PHPStan to check reads precisely, prefer array-like access with literal keys (for example `$user['lastName']`) or narrowed `meta()` keys on these array-shape-based models. Do not combine array-shape annotations and `@property` tags on the same model.
```php
/**
diff --git a/phpstan.neon b/phpstan.neon
index d865762..ff963b3 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -4,3 +4,9 @@ parameters:
paths:
- %currentWorkingDirectory%/src/
- %currentWorkingDirectory%/tests/
+
+services:
+ -
+ class: Arrayy\PHPStan\MetaDynamicStaticMethodReturnTypeExtension
+ tags:
+ - phpstan.broker.dynamicStaticMethodReturnTypeExtension
diff --git a/src/Arrayy.php b/src/Arrayy.php
index 105d428..55f51ad 100644
--- a/src/Arrayy.php
+++ b/src/Arrayy.php
@@ -117,6 +117,7 @@ class Arrayy extends \ArrayObject implements \IteratorAggregate, \ArrayAccess, \
* true, otherwise this option didn't not work anyway.
*
*
+ * @phpstan-param TData|self|\Traversable|callable|object|scalar|null $data
* @phpstan-param class-string<\Arrayy\ArrayyIterator> $iteratorClass
*/
public function __construct(
@@ -1752,6 +1753,7 @@ public function countValues(): self
* @return static
* (Immutable) Returns an new instance of the Arrayy object.
*
+ * @phpstan-param TData|self|\Traversable|callable|object|scalar|null $data
* @phpstan-param class-string<\Arrayy\ArrayyIterator> $iteratorClass
* @phpstan-return static
* @psalm-mutation-free
diff --git a/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php b/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php
new file mode 100644
index 0000000..feb0c21
--- /dev/null
+++ b/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php
@@ -0,0 +1,63 @@
+
+ */
+ private array $types = [];
+
+ public function getClass(): string
+ {
+ return Arrayy::class;
+ }
+
+ public function isStaticMethodSupported(MethodReflection $methodReflection): bool
+ {
+ return $methodReflection->getName() === 'meta';
+ }
+
+ public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): ?Type
+ {
+ if (!$methodCall->class instanceof Name) {
+ return null;
+ }
+
+ $className = $scope->resolveName($methodCall->class);
+ if (!\is_a($className, Arrayy::class, true)) {
+ return null;
+ }
+
+ if (isset($this->types[$className])) {
+ return $this->types[$className];
+ }
+
+ /** @var \Arrayy\ArrayyMeta $meta */
+ $meta = $className::meta();
+ $properties = [];
+ foreach (\get_object_vars($meta) as $propertyName => $value) {
+ if (!\is_string($propertyName) || !\is_string($value)) {
+ continue;
+ }
+
+ $properties[$propertyName] = new ConstantStringType($value);
+ }
+
+ // Passing an empty optionalProperties list makes every inferred meta key concrete/required.
+ return $this->types[$className] = new ObjectShapeType($properties, []);
+ }
+}
diff --git a/tests/MetaDynamicStaticMethodReturnTypeExtensionTest.php b/tests/MetaDynamicStaticMethodReturnTypeExtensionTest.php
new file mode 100644
index 0000000..2891b73
--- /dev/null
+++ b/tests/MetaDynamicStaticMethodReturnTypeExtensionTest.php
@@ -0,0 +1,104 @@
+getClass());
+ }
+
+ public function testIsStaticMethodSupportedOnlyForMeta(): void
+ {
+ $extension = new MetaDynamicStaticMethodReturnTypeExtension();
+
+ $metaMethod = $this->createMock(MethodReflection::class);
+ $metaMethod->method('getName')->willReturn('meta');
+
+ $createMethod = $this->createMock(MethodReflection::class);
+ $createMethod->method('getName')->willReturn('create');
+
+ self::assertTrue($extension->isStaticMethodSupported($metaMethod));
+ self::assertFalse($extension->isStaticMethodSupported($createMethod));
+ }
+
+ public function testReturnsNullWhenStaticCallClassIsNotANameNode(): void
+ {
+ $extension = new MetaDynamicStaticMethodReturnTypeExtension();
+
+ $method = $this->createMock(MethodReflection::class);
+ $scope = $this->createMock(Scope::class);
+ $scope->expects(self::never())->method('resolveName');
+
+ $type = $extension->getTypeFromStaticMethodCall(
+ $method,
+ new StaticCall(new Variable('className'), 'meta'),
+ $scope
+ );
+
+ self::assertNull($type);
+ }
+
+ public function testReturnsNullForNonArrayyClasses(): void
+ {
+ $extension = new MetaDynamicStaticMethodReturnTypeExtension();
+
+ $method = $this->createMock(MethodReflection::class);
+ $scope = $this->createMock(Scope::class);
+ $scope->expects(self::once())
+ ->method('resolveName')
+ ->willReturn(\stdClass::class);
+
+ $type = $extension->getTypeFromStaticMethodCall(
+ $method,
+ new StaticCall(new Name('stdClass'), 'meta'),
+ $scope
+ );
+
+ self::assertNull($type);
+ }
+
+ public function testBuildsAndCachesMetaShapeTypes(): void
+ {
+ $extension = new MetaDynamicStaticMethodReturnTypeExtension();
+
+ $method = $this->createMock(MethodReflection::class);
+ $scope = $this->createMock(Scope::class);
+ $scope->expects(self::exactly(2))
+ ->method('resolveName')
+ ->willReturn(ArrayShapeUser::class);
+
+ $call = new StaticCall(new Name('ArrayShapeUser'), 'meta');
+
+ $firstType = $extension->getTypeFromStaticMethodCall($method, $call, $scope);
+ $secondType = $extension->getTypeFromStaticMethodCall($method, $call, $scope);
+
+ self::assertInstanceOf(ObjectShapeType::class, $firstType);
+ self::assertSame($firstType, $secondType);
+ self::assertSame(
+ "object{id: 'id', firstName: 'firstName', lastName: 'lastName', city: 'city'}",
+ $firstType->describe(VerbosityLevel::precise())
+ );
+ }
+}
diff --git a/tests/MetaPhpStanIntegrationTest.php b/tests/MetaPhpStanIntegrationTest.php
new file mode 100644
index 0000000..2f530d2
--- /dev/null
+++ b/tests/MetaPhpStanIntegrationTest.php
@@ -0,0 +1,102 @@
+assertFixturePassesPhpStan('MetaValidUsage.php');
+ }
+
+ public function testPhpStanAcceptsValidArrayShapeUsage(): void
+ {
+ $this->assertFixturePassesPhpStan('ArrayShapeValidUsage.php');
+ }
+
+ public function testPhpStanRejectsInvalidMetaUsage(): void
+ {
+ $output = $this->assertFixtureFailsPhpStan('MetaInvalidUsage.php');
+
+ static::assertStringContainsString('Access to an undefined property', $output);
+ static::assertStringContainsString('$ghost', $output);
+ static::assertStringContainsString('strlen', $output);
+ static::assertStringContainsString('expects string', $output);
+ static::assertStringContainsString('int|null', $output);
+ }
+
+ public function testPhpStanRejectsInvalidArrayShapeUsage(): void
+ {
+ $output = $this->assertFixtureFailsPhpStan('ArrayShapeInvalidUsage.php');
+
+ static::assertStringContainsString('Parameter #1 $data of class Arrayy\tests\PHPStan\ArrayShapeUser constructor expects', $output);
+ static::assertStringContainsString("array{id: 'wrong', firstName: 'Lars', lastName: 'Moelleken'} given", $output);
+ static::assertStringContainsString("array{id: 1, firstName: 'Lars'} given", $output);
+ static::assertStringContainsString('Parameter #1 $data of static method Arrayy\Arrayy', $output);
+ }
+
+ private function assertFixturePassesPhpStan(string $fixtureFile): void
+ {
+ [$exitCode, $stdout, $stderr] = $this->runPhpStanFixture($fixtureFile);
+ $output = \trim($stdout . $stderr);
+
+ static::assertSame(0, $exitCode, $output);
+ }
+
+ private function assertFixtureFailsPhpStan(string $fixtureFile): string
+ {
+ [$exitCode, $stdout, $stderr] = $this->runPhpStanFixture($fixtureFile);
+ $output = \trim($stdout . $stderr);
+
+ static::assertSame(1, $exitCode, $output);
+
+ return $output;
+ }
+
+ /**
+ * @return array{0: int, 1: string, 2: string}
+ */
+ private function runPhpStanFixture(string $fixtureFile): array
+ {
+ $repoRoot = \dirname(__DIR__);
+ $command = [
+ \PHP_BINARY,
+ $repoRoot . '/vendor/bin/phpstan',
+ 'analyse',
+ '--no-progress',
+ '--error-format=raw',
+ '--configuration=' . $repoRoot . '/phpstan.neon',
+ $repoRoot . '/tests/PHPStan/' . $fixtureFile,
+ ];
+
+ $descriptorSpec = [
+ 1 => ['pipe', 'w'],
+ 2 => ['pipe', 'w'],
+ ];
+
+ $process = \proc_open($command, $descriptorSpec, $pipes, $repoRoot);
+ static::assertIsResource($process);
+
+ $stdout = \stream_get_contents($pipes[1]) ?: '';
+ $stderr = \stream_get_contents($pipes[2]) ?: '';
+
+ \fclose($pipes[1]);
+ \fclose($pipes[2]);
+
+ $exitCode = \proc_close($process);
+
+ return [$exitCode, (string) $stdout, (string) $stderr];
+ }
+}
diff --git a/tests/MetaRuntimeTest.php b/tests/MetaRuntimeTest.php
new file mode 100644
index 0000000..64bf969
--- /dev/null
+++ b/tests/MetaRuntimeTest.php
@@ -0,0 +1,53 @@
+name => 'Düsseldorf',
+ $cityMeta->plz => null,
+ ]);
+
+ $userMeta = ArrayShapeUser::meta();
+ $user = new ArrayShapeUser([
+ $userMeta->id => 1,
+ $userMeta->firstName => 'Lars',
+ $userMeta->lastName => 'Moelleken',
+ $userMeta->city => $city,
+ ]);
+
+ static::assertSame('id', $userMeta->id);
+ static::assertSame('city', $userMeta->city);
+ static::assertSame('name', $cityMeta->name);
+ static::assertSame(1, $user[$userMeta->id]);
+ static::assertInstanceOf(ArrayShapeCity::class, $user[$userMeta->city]);
+ static::assertSame('Düsseldorf', $user[$userMeta->city][$cityMeta->name]);
+ }
+
+ public function testArrayShapeMetaRejectsWrongRuntimeTypes(): void
+ {
+ $this->expectException(\TypeError::class);
+ $this->expectExceptionMessageMatches('/Invalid type: expected "id" to be of type \\{int\\}/');
+
+ $userMeta = ArrayShapeUser::meta();
+ $user = new ArrayShapeUser([
+ $userMeta->id => 1,
+ $userMeta->firstName => 'Lars',
+ $userMeta->lastName => 'Moelleken',
+ ]);
+
+ $user[$userMeta->id] = 'wrong-id';
+ }
+}
diff --git a/tests/PHPStan/ArrayShapeAccessTest.php b/tests/PHPStan/ArrayShapeAccessTest.php
index 66e781a..a13539c 100644
--- a/tests/PHPStan/ArrayShapeAccessTest.php
+++ b/tests/PHPStan/ArrayShapeAccessTest.php
@@ -11,6 +11,19 @@
*/
final class ArrayShapeAccessTest extends \PHPUnit\Framework\TestCase
{
+ /**
+ * @template TCity of array{name: string, plz?: string|null}
+ *
+ * @param ArrayShapeCity|null $city
+ */
+ private static function assertNullableCity(?ArrayShapeCity $city): void
+ {
+ }
+
+ private static function assertNullableString(?string $value): void
+ {
+ }
+
public function testArrayShapeOffsetsAreTyped(): void
{
$user = new ArrayShapeUser([
@@ -26,12 +39,12 @@ public function testArrayShapeOffsetsAreTyped(): void
\PHPStan\Testing\assertType('int|null', $user['id']);
\PHPStan\Testing\assertType('string|null', $user['firstName']);
\PHPStan\Testing\assertType('string|null', $user['lastName']);
- \PHPStan\Testing\assertType('Arrayy\tests\PHPStan\ArrayShapeCity|null', $user['city']);
+ self::assertNullableCity($user['city']);
if ($user['city'] !== null) {
- \PHPStan\Testing\assertType('Arrayy\tests\PHPStan\ArrayShapeCity', $user['city']);
- \PHPStan\Testing\assertType('string|null', $user['city']['name']);
- \PHPStan\Testing\assertType('string|null', $user['city']['plz']);
+ self::assertNullableCity($user['city']);
+ self::assertNullableString($user['city']['name']);
+ self::assertNullableString($user['city']['plz']);
}
self::assertSame('Moelleken', $user['lastName']);
diff --git a/tests/PHPStan/ArrayShapeInvalidUsage.php b/tests/PHPStan/ArrayShapeInvalidUsage.php
new file mode 100644
index 0000000..0242b69
--- /dev/null
+++ b/tests/PHPStan/ArrayShapeInvalidUsage.php
@@ -0,0 +1,24 @@
+ 'wrong',
+ 'firstName' => 'Lars',
+ 'lastName' => 'Moelleken',
+]);
+
+new ArrayShapeUser([
+ 'id' => 1,
+ 'firstName' => 'Lars',
+]);
+
+ArrayShapeUser::create([
+ 'id' => 'wrong',
+ 'firstName' => 'Lars',
+ 'lastName' => 'Moelleken',
+]);
diff --git a/tests/PHPStan/ArrayShapeValidUsage.php b/tests/PHPStan/ArrayShapeValidUsage.php
new file mode 100644
index 0000000..ffc1d63
--- /dev/null
+++ b/tests/PHPStan/ArrayShapeValidUsage.php
@@ -0,0 +1,49 @@
+|null $city
+ */
+function assertValidArrayShapeNullableCity(?ArrayShapeCity $city): void
+{
+}
+
+/**
+ * @template TCity of array{name: string, plz?: string|null}
+ *
+ * @param ArrayShapeCity $city
+ */
+function assertValidArrayShapeCity(ArrayShapeCity $city): void
+{
+}
+
+function assertValidArrayShapeNullableString(?string $value): void
+{
+}
+
+$user = new ArrayShapeUser([
+ 'id' => 1,
+ 'firstName' => 'Lars',
+ 'lastName' => 'Moelleken',
+ 'city' => new ArrayShapeCity([
+ 'name' => 'Düsseldorf',
+ 'plz' => null,
+ ]),
+]);
+
+\PHPStan\Testing\assertType('int|null', $user['id']);
+\PHPStan\Testing\assertType('string|null', $user['firstName']);
+assertValidArrayShapeNullableCity($user['city']);
+
+if ($user['city'] !== null) {
+ assertValidArrayShapeCity($user['city']);
+ \PHPStan\Testing\assertType('string|null', $user['city']['name']);
+ assertValidArrayShapeNullableString($user['city']['plz']);
+}
diff --git a/tests/PHPStan/MetaInvalidUsage.php b/tests/PHPStan/MetaInvalidUsage.php
new file mode 100644
index 0000000..0b61ef0
--- /dev/null
+++ b/tests/PHPStan/MetaInvalidUsage.php
@@ -0,0 +1,23 @@
+id => 1,
+ $userMeta->firstName => 'Lars',
+ $userMeta->lastName => 'Moelleken',
+ $userMeta->city => new ArrayShapeCity([
+ $cityMeta->name => 'Düsseldorf',
+ $cityMeta->plz => null,
+ ]),
+]);
+
+$ghost = $userMeta->ghost;
+$length = \strlen($user[$userMeta->id]);
diff --git a/tests/PHPStan/MetaValidUsage.php b/tests/PHPStan/MetaValidUsage.php
new file mode 100644
index 0000000..12d910c
--- /dev/null
+++ b/tests/PHPStan/MetaValidUsage.php
@@ -0,0 +1,50 @@
+|null $city
+ */
+function assertMetaFixtureNullableCity(?ArrayShapeCity $city): void
+{
+}
+
+/**
+ * @template TCity of array{name: string, plz?: string|null}
+ *
+ * @param ArrayShapeCity $city
+ */
+function assertMetaFixtureCity(ArrayShapeCity $city): void
+{
+}
+
+$cityMeta = ArrayShapeCity::meta();
+$userMeta = ArrayShapeUser::meta();
+
+\PHPStan\Testing\assertType("'id'", $userMeta->id);
+\PHPStan\Testing\assertType("'city'", $userMeta->city);
+\PHPStan\Testing\assertType("'name'", $cityMeta->name);
+
+$user = new ArrayShapeUser([
+ $userMeta->id => 1,
+ $userMeta->firstName => 'Lars',
+ $userMeta->lastName => 'Moelleken',
+ $userMeta->city => new ArrayShapeCity([
+ $cityMeta->name => 'Düsseldorf',
+ $cityMeta->plz => null,
+ ]),
+]);
+
+\PHPStan\Testing\assertType('int|null', $user[$userMeta->id]);
+assertMetaFixtureNullableCity($user[$userMeta->city]);
+
+if ($user[$userMeta->city] !== null) {
+ assertMetaFixtureCity($user[$userMeta->city]);
+ \PHPStan\Testing\assertType('string|null', $user[$userMeta->city][$cityMeta->name]);
+}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
index f1953e1..791b6dc 100644
--- a/tests/bootstrap.php
+++ b/tests/bootstrap.php
@@ -3,4 +3,9 @@
\error_reporting(\E_ALL);
\ini_set('display_errors', '1');
+$buildLogsDir = __DIR__ . '/../build/logs';
+if (!\is_dir($buildLogsDir)) {
+ \mkdir($buildLogsDir, 0700, true);
+}
+
require_once __DIR__ . '/../vendor/autoload.php';