array{foo: int} sealed?... declares the keys it knows about
and says nothing out loud about the rest, so the default has to be read off
behaviour: sealed means an undeclared key is a type error, unsealed means it
is admitted the way array{foo: int, ...} admits it. PHPStan and Psalm both
seal by default — that is what makes ... worth spelling — and an analyzer
that leaves shapes open instead accepts a payload the declaration never
promised to handle.
The first two calls are controls rather than the question: every analyzer
that models shapes at all rejects them, so silence there means shapes are not
checked, while silence on the undeclared key alone means the shape is open.
References:
- PHPStan array shapes
- Psalm "Unsealed array and list shapes", where ... is the opt-in| Analyzer | Version | Result | Diagnostics |
|---|---|---|---|
| phan | 6.0.7Phan 6.0.7 php-ast version 1.1.3 PHP version used to run Phan: 8.5.9 | Fail | arrays_shape_sealed_by_default.php:35: Argument 1 ($shape) is [] of type array{} but \Conformance\Tests\ArraysShapeSealedByDefault\takesShape() takes array{foo:int} defined at /Users/megurine/repo/php/php-typing-conformance/conformance/tests/arrays_shape_sealed_by_default.php:30 [PhanTypeMismatchArgument]
arrays_shape_sealed_by_default.php:36: Argument 1 ($shape) is ['foo'=>'one'] of type array{foo:'one'} but \Conformance\Tests\ArraysShapeSealedByDefault\takesShape() takes array{foo:int} defined at /Users/megurine/repo/php/php-typing-conformance/conformance/tests/arrays_shape_sealed_by_default.php:30 [PhanTypeMismatchArgument]Expectation diffLine 40: Expected 1 error(s) Line 49: Expected 1 error(s) Notes: Shapes are open by default: `array{foo: int}` admits the undeclared `buz`, though the Phan wiki introduces the syntax as an "exact array structure". Phan also cannot parse the explicit `...` (arrays_unsealed_shape), so neither spelling seals and neither is rejected. |
| phpstan | 2.2.8PHPStan - PHP Static Analysis Tool 2.2.8 | Pass reported Lv.5+ | arrays_shape_sealed_by_default.php:35: Parameter #1 $shape of function Conformance\Tests\ArraysShapeSealedByDefault\takesShape expects array{foo: int}, array{} given. [identifier=argument.type] [reported-from-level=5]
arrays_shape_sealed_by_default.php:36: Parameter #1 $shape of function Conformance\Tests\ArraysShapeSealedByDefault\takesShape expects array{foo: int}, array{foo: 'one'} given. [identifier=argument.type] [reported-from-level=5]
arrays_shape_sealed_by_default.php:40: Parameter #1 $shape of function Conformance\Tests\ArraysShapeSealedByDefault\takesShape expects array{foo: int}, array{foo: 2, buz: 42.0} given. [identifier=argument.type] [reported-from-level=5]
arrays_shape_sealed_by_default.php:49: Parameter #1 $shape of function Conformance\Tests\ArraysShapeSealedByDefault\takesShape expects array{foo: int}, array{foo: int, buz: float} given. [identifier=argument.type] [reported-from-level=5]With strict-rulesarrays_shape_sealed_by_default.php:35: Parameter #1 $shape of function Conformance\Tests\ArraysShapeSealedByDefault\takesShape expects array{foo: int}, array{} given. [identifier=argument.type]
arrays_shape_sealed_by_default.php:36: Parameter #1 $shape of function Conformance\Tests\ArraysShapeSealedByDefault\takesShape expects array{foo: int}, array{foo: 'one'} given. [identifier=argument.type]
arrays_shape_sealed_by_default.php:40: Parameter #1 $shape of function Conformance\Tests\ArraysShapeSealedByDefault\takesShape expects array{foo: int}, array{foo: 2, buz: 42.0} given. [identifier=argument.type]
arrays_shape_sealed_by_default.php:49: Parameter #1 $shape of function Conformance\Tests\ArraysShapeSealedByDefault\takesShape expects array{foo: int}, array{foo: int, buz: float} given. [identifier=argument.type] |
| psalm | 6.16.1Psalm 6.16.1@f1f5de594dc76faf8784e02d3dc4716c91c6f6ac next: 7.0.0-beta19 |
Pass | arrays_shape_sealed_by_default.php:35: Argument 1 of Conformance\Tests\ArraysShapeSealedByDefault\takesShape expects array{foo: int}, but array<never, never> provided [InvalidArgument]
arrays_shape_sealed_by_default.php:36: Argument 1 of Conformance\Tests\ArraysShapeSealedByDefault\takesShape expects array{foo: int}, but array{foo: 'one'} provided [InvalidArgument]
arrays_shape_sealed_by_default.php:40: Argument 1 of Conformance\Tests\ArraysShapeSealedByDefault\takesShape expects array{foo: int}, but array{buz: float(42), foo: 2} with additional array shape fields (buz) was provided [InvalidArgument]
arrays_shape_sealed_by_default.php:49: Argument 1 of Conformance\Tests\ArraysShapeSealedByDefault\takesShape expects array{foo: int}, but array{buz: float, foo: int} with additional array shape fields (buz) was provided [InvalidArgument] |
| mago | 1.46.0mago 1.46.0 | Pass | arrays_shape_sealed_by_default.php:35: Possible argument type mismatch for argument #1 of `Conformance\Tests\ArraysShapeSealedByDefault\takesShape`: expected `array{'foo': int}`, but possibly received `array{}`. [possibly-invalid-argument]
arrays_shape_sealed_by_default.php:36: Possible argument type mismatch for argument #1 of `Conformance\Tests\ArraysShapeSealedByDefault\takesShape`: expected `array{'foo': int}`, but possibly received `array{'foo': string('one')}`. [possibly-invalid-argument]
arrays_shape_sealed_by_default.php:40: Possible argument type mismatch for argument #1 of `Conformance\Tests\ArraysShapeSealedByDefault\takesShape`: expected `array{'foo': int}`, but possibly received `array{'buz': float(42.0), 'foo': int(2)}`. [possibly-invalid-argument]
arrays_shape_sealed_by_default.php:49: Possible argument type mismatch for argument #1 of `Conformance\Tests\ArraysShapeSealedByDefault\takesShape`: expected `array{'foo': int}`, but possibly received `array{'buz': float, 'foo': int}`. [possibly-invalid-argument] |
| mir | 0.70.1mir 0.70.1 | Fail | arrays_shape_sealed_by_default.php:35: InvalidArgument: Argument $shape of takesShape() expects 'array{'foo': int}', got 'array{}' [MIR0201]
arrays_shape_sealed_by_default.php:36: InvalidArgument: Argument $shape of takesShape() expects 'array{'foo': int}', got 'array{'foo': "one"}' [MIR0201]Expectation diffLine 40: Expected 1 error(s) Line 49: Expected 1 error(s) Notes: Shapes are open by default: the missing key and the mistyped value are caught, but the undeclared `buz` is admitted both from a literal and from a wider declared shape. mir accepts the same calls under `array{foo: int, ...}` (arrays_unsealed_shape), so the two spellings are indistinguishable to it. |
| phpantom | 0.9.0phpantom_lsp 0.9.0 | Fail | No diagnostics reported. Expectation diffLine 35: Expected 1 error(s) Line 36: Expected 1 error(s) Line 40: Expected 1 error(s) Line 49: Expected 1 error(s) |
| intelephense | 1.18.5intelephense 1.18.5 | Fail | No diagnostics reported. Expectation diffLine 35: Expected 1 error(s) Line 36: Expected 1 error(s) Line 40: Expected 1 error(s) Line 49: Expected 1 error(s) Notes: Checks nothing here rather than answering the question: Intelephense does not compare arguments against array shapes at all, so even the missing-key and wrong-value controls go unreported. |
| phpy | 0.2.0phpy 0.2.0 | Fail | No diagnostics reported. Expectation diffLine 35: Expected 1 error(s) Line 36: Expected 1 error(s) Line 40: Expected 1 error(s) Line 49: Expected 1 error(s) Notes: Checks nothing here rather than answering the question: phpy does not compare arguments against array shapes at all, so even the missing-key and wrong-value controls go unreported. |
| qodana | 262.8665.325Qodana 262.8665.325 | Fail | arrays_shape_sealed_by_default.php:35: Incomplete array according to shape declaration. Missing key(s): foo [PhpMissingArrayKeyInspection] arrays_shape_sealed_by_default.php:36: Expected parameter of type 'int', 'string' provided [PhpParamsInspection] arrays_shape_sealed_by_default.php:40: Value should be one of: "foo" [PhpArrayKeyDoesNotMatchArrayShapeInspection] Expectation diffLine 49: Expected 1 error(s) Notes: Seals the shape against the literal at line 40 but not against a value whose extra key comes from a declaration: `array{foo: int, buz: float}` passes through at line 49. Same split as steins. |
| noverify | 0.5.5NoVerify, version 0.5.5: built on: 2025.04.22 14:36:46 OS: r-kuchinskas arm64 Commit: 4774b82f8adc53fbef38f95e97af4b953b4d1529 | Fail | arrays_shape_sealed_by_default.php:30: param $shape miss matched with phpdoc type <<array{foo: int}>> [funcParamTypeMissMatch]
arrays_shape_sealed_by_default.php:45: param $wider miss matched with phpdoc type <<array{foo: int, buz: float}>> [funcParamTypeMissMatch]Expectation diffLine 35: Expected 1 error(s)
Line 36: Expected 1 error(s)
Line 40: Expected 1 error(s)
Line 49: Expected 1 error(s)
Line 30: Unexpected errors ["param $shape miss matched with phpdoc type <<array{foo: int}>> [funcParamTypeMissMatch]"]
Line 45: Unexpected errors ["param $wider miss matched with phpdoc type <<array{foo: int, buz: float}>> [funcParamTypeMissMatch]"]Notes: Does not model array shapes at all. It rejects the shape spelling itself on both declaration lines (30 and 45) and checks none of the call sites, so it has no default to read off. |
| steins | 0.1.5steins 0.1.5 (2026-08-12 revision 39b4cc1) | Pass | arrays_shape_sealed_by_default.php:35: argument [] to takesShape() violates declared @param array{foo: int} $shape — declared contract violation [phpdoc.param-mismatch]
arrays_shape_sealed_by_default.php:36: argument ['foo' => 'one'] to takesShape() violates declared @param array{foo: int} $shape — declared contract violation [phpdoc.param-mismatch]
arrays_shape_sealed_by_default.php:40: argument ['foo' => 2, 'buz' => 42.0] to takesShape() violates declared @param array{foo: int} $shape — declared contract violation [phpdoc.param-mismatch]
arrays_shape_sealed_by_default.php:49: argument a value of type array{buz: mixed, foo: int} to takesShape() violates declared @param array{foo: int} $shape — declared contract violation [phpdoc.param-mismatch]Notes: Seals the shape against the literal at line 40 but not against a value whose extra key comes from a declaration: `array{foo: int, buz: float}` passes through at line 49. |
| phpstan-strict | 2.2.8PHPStan - PHP Static Analysis Tool 2.2.8 | Pass | arrays_shape_sealed_by_default.php:35: Parameter #1 $shape of function Conformance\Tests\ArraysShapeSealedByDefault\takesShape expects array{foo: int}, array{} given. [identifier=argument.type]
arrays_shape_sealed_by_default.php:36: Parameter #1 $shape of function Conformance\Tests\ArraysShapeSealedByDefault\takesShape expects array{foo: int}, array{foo: 'one'} given. [identifier=argument.type]
arrays_shape_sealed_by_default.php:40: Parameter #1 $shape of function Conformance\Tests\ArraysShapeSealedByDefault\takesShape expects array{foo: int}, array{foo: 2, buz: 42.0} given. [identifier=argument.type]
arrays_shape_sealed_by_default.php:49: Parameter #1 $shape of function Conformance\Tests\ArraysShapeSealedByDefault\takesShape expects array{foo: int}, array{foo: int, buz: float} given. [identifier=argument.type] |
| psalm-next | 7.0.0-beta19Psalm 7.0.0-beta19@7e751c06a756fa64dc4c759c09fe4a173afcb433 | Pass | arrays_shape_sealed_by_default.php:35: Argument 1 of Conformance\Tests\ArraysShapeSealedByDefault\takesShape expects array{foo: int}, but array<never, never> provided [InvalidArgument]
arrays_shape_sealed_by_default.php:36: Argument 1 of Conformance\Tests\ArraysShapeSealedByDefault\takesShape expects array{foo: int}, but array{foo: 'one'} provided [InvalidArgument]
arrays_shape_sealed_by_default.php:40: Argument 1 of Conformance\Tests\ArraysShapeSealedByDefault\takesShape expects array{foo: int}, but array{buz: float(42), foo: 2} with additional array shape fields (buz) was provided [InvalidArgument]
arrays_shape_sealed_by_default.php:49: Argument 1 of Conformance\Tests\ArraysShapeSealedByDefault\takesShape expects array{foo: int}, but array{buz: float, foo: int} with additional array shape fields (buz) was provided [InvalidArgument] |
<?php
declare(strict_types=1);
namespace Conformance\Tests\ArraysShapeSealedByDefault;
/**
* Is `array{foo: int}` sealed?
*
* A shape written without a trailing `...` declares the keys it knows about
* and says nothing out loud about the rest, so the default has to be read off
* behaviour: sealed means an undeclared key is a type error, unsealed means it
* is admitted the way `array{foo: int, ...}` admits it. PHPStan and Psalm both
* seal by default — that is what makes `...` worth spelling — and an analyzer
* that leaves shapes open instead accepts a payload the declaration never
* promised to handle.
*
* The first two calls are controls rather than the question: every analyzer
* that models shapes at all rejects them, so silence there means shapes are not
* checked, while silence on the undeclared key alone means the shape is open.
*
* References:
* - PHPStan array shapes
* - Psalm "Unsealed array and list shapes", where `...` is the opt-in
*/
/**
* @param array{foo: int} $shape
*/
function takesShape(array $shape): void
{
}
// Controls: the declared key is required, and it is an int.
takesShape([]); // E: array{} does not have the required key foo
takesShape(['foo' => 'one']); // E: foo is int, not string
// The question, asked of a literal.
takesShape(['foo' => 1]);
takesShape(['foo' => 2, 'buz' => 42.0]); // E: a sealed array{foo: int} does not admit the undeclared key buz
/**
* @param array{foo: int, buz: float} $wider
*/
function passesWiderShapeAlong(array $wider): void
{
// And of a value whose extra key comes from a declaration rather than from
// a literal at the call site.
takesShape($wider); // E: array{foo: int, buz: float} is not a subtype of a sealed array{foo: int}
}