Skip to content

fix: Deserialize array-typed form parameters to arrays (#58) - #59

Open
shadowhand wants to merge 1 commit into
duyler:mainfrom
shadowhand:fix/58-form-array-deserialization
Open

fix: Deserialize array-typed form parameters to arrays (#58)#59
shadowhand wants to merge 1 commit into
duyler:mainfrom
shadowhand:fix/58-form-array-deserialization

Conversation

@shadowhand

Copy link
Copy Markdown

Closes #58.

The bug

ParameterDeserializer::deserializeForm() decides whether a non-exploded form
value is an array by looking for a comma in the string, rather than by looking at
the declared schema type:

if (false === $explode && str_contains($value, ',')) {
    $this->assertWithinItemLimit($value, ',');

    return explode(',', $value);
}

return $value;

So against type: array with explode: false, a value carrying two items
deserializes to a list, but a value carrying one item stays a string and then
fails the type: array check with TypeMismatchError. The result is an array
parameter that rejects exactly one item while accepting two. The empty case is
dropped the same way: ?include= yields '' rather than [].

For a JSON:API include parameter that means ?include=author,comments is
accepted and ?include=author is a 400.

Before / after

Running the reproduction from #58 verbatim:

Before

/articles?include=author,comments        PASS
/articles?include=author                 TypeMismatchError: Expected type "array", but got "string" at /

After

/articles?include=author,comments        PASS
/articles?include=author                 PASS

The fix

form was the only style branch using the separator-sniffing heuristic.
deserializeSimple(), deserializeMatrix(), deserializeLabel() and
deserializeCookie() all route on isArrayType($param) and pass the value
through splitBySeparator(), which already returns a one element list for a lone
value and an empty list for an empty one. Form now does the same, and the comma
heuristic stays as the fallback for parameters that declare no array type:

if ($param->explode) {
    return $value;
}

if ($this->isArrayType($param)) {
    return $this->splitBySeparator($value, ',');
}

if (str_contains($value, ',')) {
    $this->assertWithinItemLimit($value, ',');

    return explode(',', $value);
}

return $value;

deserializeForm() takes the Parameter instead of just bool $explode, so it
can consult the schema — the same signature the four other type-aware branches
already have.

explode: true and array-valued input (repeated or bracketed keys) are untouched:
exploded values still pass through unsplit, arrays still implode.

Tests

ParameterDeserializerTest — four unit cases on the array-typed form parameter:
single value → ['solo'], comma-separated → three elements, empty → [], and a
type: ['array', 'null'] union to cover the isArrayType() list branch.

QueryParameterEdgeCasesTest::form_array_accepts_any_item_count drives the
reported JSON:API include shape through full request validation via the public
builder, at two items, one item, and none.

I verified these tests actually catch the bug rather than merely describing the
new code: with the src/ change reverted and the tests kept, 4 of the unit and
header cases fail and 2 of the 3 functional cases error with
Expected type "array", but got "string" — while the "two items" case passes,
which is precisely the asymmetry the issue reports.

One existing test re-pointed

HeaderValidationTest::header_array_type_with_single_value_throws_type_mismatch
asserted the old behaviour. Its schema is type: array, minItems: 2 and the value
is 'solo', so the request is still rejected after the fix — as MinItemsError
("Array has 1 items, but minimum is 2"), which is the accurate reason. "A lone
value is not an array" was the wrong one. Renamed to
header_array_type_with_single_value_throws_min_items accordingly.

Verification

  • make tests — 7138 tests, 14729 assertions, 0 failures. (The 2 reported
    deprecations are pre-existing, in unrelated files.)
  • make psalm — 0 errors.
  • make cs-fix — Fixed 0 of 877 files.
  • make rector — OK.

Notes

  • No inline comment was added to src/. 4e5baf8 states that after the §12
    cleanup "all inline // comments are resolved: only the machine-greppable ADR
    exemption marker remains", and grep -rn "^\s*//" src/ confirms a comment here
    would be the only one. The isArrayType() call reads the same as it does in the
    four sibling branches, and the tests plus the CHANGELOG entry carry the
    rationale. Happy to add one if you would prefer it called out in the source.
  • Two adjacent problems in the same function are deliberately left alone, noted in
    case they are of interest:
    1. The mirror case. The comma heuristic still fires when the schema declares
      type: string, so ?q=a,b deserializes to ['a', 'b'] and fails the string
      check. Same root cause — shape decided without consulting the type — but
      fixing it changes behaviour for parameters this issue does not cover.
    2. explode: true with an array type. ?tags=php against type: array
      also stays a scalar and fails. This one is entangled with the explode
      default: OpenAPI specifies explode: true for style: form, but
      Parameter::$explode defaults to false for every style, and
      QueryParameterEdgeCasesTest::qp_02/qp_07 are built on that default.
      Worth its own issue and its own decision.

Checklist

  • Tests added for new behaviour
  • CHANGELOG.md updated
  • BC-break documented in CHANGELOG ### BREAKING (N/A — see below)
  • make tests green
  • make psalm clean (0 errors)
  • make cs-fix clean
  • make rector clean

On BC: a spec that declares type: array and relies on a single value
deserializing to a string would have been failing validation already, so there
is no working behaviour to break. deserializeForm() is private, so its
signature change is not public API.

ParameterDeserializer::deserializeForm() decided the shape of a
non-exploded value by looking for a comma instead of at the declared
schema type, so `?tags=a,b` produced ["a","b"] but `?tags=a` produced
the string "a". Against `type: array` the single-value case failed with
TypeMismatchError, making an array parameter reject exactly one item
while accepting two.

Every other style branch -- simple, matrix, label, cookie -- already
routes on isArrayType(). Form now does the same: when the schema is
array-typed the value goes through splitBySeparator(), which yields a
one item list for a lone value and an empty list for an empty one, and
the comma heuristic stays as the fallback for parameters that declare
no array type.

Explode is untouched: exploded values still pass through unsplit, and
array inputs from repeated or bracketed keys still implode as before.

header_array_type_with_single_value_throws_type_mismatch asserted the
old behaviour. Its `minItems: 2` schema still rejects the single value,
now as MinItemsError -- "a lone value is not an array" was the wrong
reason -- so it is renamed accordingly.

QueryParameterEdgeCasesTest::form_array_accepts_any_item_count covers
the reported JSON:API `include` case end to end -- two items, one item,
and none -- through full request validation.
@shadowhand
shadowhand force-pushed the fix/58-form-array-deserialization branch from c7e8c12 to 202056f Compare August 11, 2026 14:04
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.

Array-typed form parameter rejects a single value: deserializeForm() sniffs for a comma instead of reading the schema type

1 participant