Skip to content

feat: implement the URL search params serialization standard - #463

Merged
razor-x merged 6 commits into
betafrom
claude/php-beta-pr-1d60h3
Aug 14, 2026
Merged

feat: implement the URL search params serialization standard#463
razor-x merged 6 commits into
betafrom
claude/php-beta-pr-1d60h3

Conversation

@razor-x

@razor-x razor-x commented Aug 13, 2026

Copy link
Copy Markdown
Member

Ports @seamapi/url-search-params-serializer to PHP, wires it into the HTTP client, and types nullable params with an explicit null sentinel. Reviewable in sequence: the serializer alone (purely additive), the client wiring and codegen typing, _strict=true support mirroring seamapi/python#617, then cleanup commits.

Why this is needed, on the wire

Before, query params went through Guzzle's http_build_query (RFC 3986 rules). Measured before/after for the same inputs:

Params Before (Guzzle) After (standard)
["device_ids" => []] (param dropped entirely — API returns everything) device_ids=&_strict=true (the empty array — returns nothing)
["custom_metadata_has" => ["tag" => "front", "floor" => 2]] custom_metadata_has%5Btag%5D=front&custom_metadata_has%5Bfloor%5D=2 custom_metadata_has.floor=2&custom_metadata_has.tag=front&_strict=true
["device_ids" => ["d1", "d2"]] device_ids%5B0%5D=d1&device_ids%5B1%5D=d2 device_ids=d1&device_ids=d2&_strict=true
["search" => "a *~ b"] search=a%20%2A~%20b search=a+*%7E+b&_strict=true
["sync" => true] sync=1 sync=true&_strict=true

The empty-array row is the severe one: HTTP 200 with the wrong data, invisible to status-code checks.

What's included

Seam\UrlSearchParamsSerializer + Seam\UrlSearchParams — the two-layer port: an ordered pair collection implementing the parts of the URLSearchParams interface the serializer needs, and the serializer walk on top (dot-joined nesting, array append vs scalar set, empty-array → name=, typed Seam\UnserializableParamError raised before any request goes out). The unit test suite mirrors the reference implementation's and the Python SDK's suites, covering every branch of the standard.

Seam\StrictUrlSearchParamsSerializer — mirrors seamapi/python#617: appends _strict=true to any non-empty query (after the sort, so it always sits last; a caller-supplied _strict is replaced, not repeated; an empty query stays empty), telling the Seam API to use strict, schema-aware parsing. The flag is Seam API behavior rather than part of the serialization standard, so it is isolated in this wrapper and the base serializer stays a pure implementation of the standard. The SDK client serializes every request with the strict wrapper.

Seam\NullValue — the explicit null sentinel, as a unit enum (NullValue::NULL), since PHP has one absence value and the API distinguishes omit from set to null. null always means omit (the safe option); sending null is always spelled explicitly. The other SDKs spell the sentinel NULL with type Null; both names are reserved in PHP, so the type and the value live on one enum. Detected by type (instanceof), impossible to forge a second instance, and replaced by real null in JSON bodies without mutating the caller's payload.

Client wiringSeam\Http\SerializingClient decorates the Guzzle client (both Seam and SeamWithoutWorkspace, including caller-supplied clients via from_client). Map queries are serialized and handed to Guzzle as a raw query string (Guzzle's escape hatch: a string query option is used verbatim); string queries pass through untouched; an empty serialization removes the option so no bare ? is emitted; NullValue::NULL in json bodies becomes JSON null.

Nullable typing in codegen — the blueprint's isNullable flag now renders as a union with the sentinel, composed with optionality rather than replacing it: nullable+optional is string|NullValue|null = null, merely optional stays ?string = null. 49 generated params across 20 route clients are nullable today (all also optional). The import is emitted only in files that reference it.

Docs — README sections aligned with the Python and Ruby SDKs: a top-level Setting a param to null section and a Serializing URL search params section under Advanced Usage, linking the reference implementation and the parser.

Sorting: stable byte order, deliberately

UrlSearchParams::sort() is a stable sort comparing names by byte. Stability is what the standard actually depends on (it preserves array element order); byte order matches JavaScript's URLSearchParams.sort() for every ASCII name, which all Seam param names are. A name beyond the Basic Multilingual Plane (an astral emoji key) may order differently than the reference against a name in U+E000–U+FFFF — an accepted, documented deviation in exchange for not carrying a UTF-16 sort key implementation.

Conformance

The serializer was verified against the reference with an external harness (not committed — the serializer is stable, and the spec-derived unit tests are the in-repo record). It generated a shared JSON fixture with tagged values so types survive the trip ({"$date": ms}, {"$null": true}, ...), fed the identical fixture to this port and to @seamapi/url-search-params-serializer v3, and diffed the outputs byte for byte. Results, re-run at the final commit:

  • 3,381 cases: 66 hand-built serializable cases + 14 cases that must error on both sides, 3,000 randomized structural-fuzz cases, and 301 float-fuzz cases carrying 30,034 floats (34 known boundaries, 20,000 random 64-bit patterns including denormals, 10,000 magnitude-sweep values across every decimal exponent).
  • 3,202 byte-for-byte identical; 179 contain exactly the same pairs in a different order, every one involving non-ASCII names above the BMP — the documented sort deviation, and nothing else. Every case with ASCII names (all real Seam params, plus all 30k floats and dates) is byte-identical, including all encoding and value formatting.
  • Round trip: parsed with @seamapi/url-search-params-parser in strict mode, 0 mismatches, against Zod schemas derived per case — both sides agree on what the bytes mean.

Stdlib functions that had to be replaced, per the probe:

  • urlencode("a *~ b") returns a+%2A%7E+b — wrong on both * and ~ — so the WHATWG form encoder is hand-written (~15 lines over the UTF-8 bytes).
  • (string) float casts render 1.0, use PHP's precision ini, and spell exponents 1.0E+21; floats instead follow the ECMAScript Number::toString algorithm, seeded with the shortest round-tripping digits from var_export at serialize_precision=-1 (set and restored around the call).
  • DateTimeInterface::format has no always-three-digit milliseconds; dates are formatted manually in UTC with microseconds truncated (never rounded). PHP datetimes always carry a timezone, so there is no naive value to interpret.
  • Sorting uses PHP's own stable usort (stable since PHP 8.0) with plain strcmp — see the sorting note above.

Verification beyond the harness

  • Wire-level tests (tests/SearchParamsTest.php) assert on the raw RequestInterface query string through the real Guzzle stack, base-URL resolution included: arrays and nested objects, the */~ non-re-encoding, absent params omitted, sentinel as name=, _strict=true on every non-empty query, no bare ?, all five verbs, string-query pass-through, the error raised with zero requests sent, the sentinel in a JSON body, and a generated route end-to-end. The raw query survives Guzzle because a string query option is applied verbatim and PSR-7's Uri::withQuery preserves *, +, %XX, &, and =.
  • Type contract: Psalm confirms devices->update(name: NullValue::NULL) is accepted while devices->update(is_managed: NullValue::NULL) is rejected (expects bool|null, but enum(Seam\NullValue::NULL) provided). Note the repo's psalm.xml deliberately excludes generated code from project scope, so this check was run with a one-off config rather than being CI-locked; the native parameter types (?bool vs string|NullValue|null) additionally enforce the contract at runtime with a TypeError.
  • Pre-existing suite: 117 tests / 212 assertions before, 203 / 396 after — the delta is exactly the four new test files (74 serializer/sentinel/strict unit tests + 12 wire-level tests); nothing dropped out of collection. The one pre-existing incomplete test and the PHP 8.4 deprecation notices from generated resource constructors are unchanged.
  • composer lint (validate, syntax, Psalm), npm run lint (eslint + prettier), and tsc are clean; re-running npm run generate produces no drift. No new dependencies, dev or runtime.

Notes and limitations

  • Which routes exercise the complex paths: the codegen sends GET/DELETE with query params and everything else as a JSON body, and the blueprint's preferredMethod falls back to POST for routes with complex params — so generated GET routes carry only scalars, and the array/nested-object query paths are reached through direct $seam->client calls (covered by the wire tests, plus a generated scalar GET route end-to-end).
  • No fake bump needed: @seamapi/fake-seam-connect 1.86.0 already parses the standard and accepts _strict=true — the pre-existing SerializationTest now sends device_ids=&_strict=true for an empty array against the fake and still passes (0 devices returned).
  • PHP array duality: [] is the empty JavaScript array (name=); an empty plain object is spelled new \stdClass(). A map key PHP would cast to an integer (e.g. "0") is rejected as a non-string key, since PHP cannot represent it as a string key — the one input class the two languages cannot share.
  • Sentinel falsiness: the requirement that the sentinel be falsy is not implementable in PHP — objects are unconditionally truthy and enums cannot override boolean conversion. Everything else about the sentinel pattern (type detection, single instance, NULL name, exported type) holds.
  • Required-nullable params: none exist in the current blueprint (all 49 nullable params are also optional), but the codegen handles the composition (string|NullValue, no default) should one appear.

claude added 2 commits August 13, 2026 19:25
Port @seamapi/url-search-params-serializer to PHP as
Seam\UrlSearchParamsSerializer over a Seam\UrlSearchParams pair
collection, byte-for-byte identical to the TypeScript reference
implementation. The unit test suite mirrors the reference and Python
SDK suites, covering every branch of the standard.

PHP's own primitives each diverge from the standard, so the port
implements them directly: urlencode() is RFC 3986 flavored (escapes *,
keeps ~) where the WHATWG form encoding does the opposite; float
casts render 1.0, switch to exponents at the wrong thresholds, and
spell them E+21, so floats follow the ECMAScript Number::toString
algorithm; sorting compares UTF-16 code units, not UTF-8 bytes; and
dates always carry exactly three fractional digits and a literal Z.

PHP has a single absence value, so the Seam\NullValue enum adds the
explicit null sentinel: null means the safe option of omitting a
param, and sending null is always spelled NullValue::NULL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HMKbYgbsoFdKiiaxXB6jh2
Wrap the Guzzle client in Seam\Http\SerializingClient so every request
follows the serialization standard. Query params given as a map are
serialized with UrlSearchParamsSerializer and handed to Guzzle as a raw
query string, since Guzzle's own encoder escapes *, keeps ~, and drops
an empty array entirely instead of sending name= (which the API reads
as the empty array rather than an unfiltered request). NullValue::NULL
sentinels in JSON bodies become JSON null, so the sentinel works on
both transports. A query already given as a string passes through
untouched, and nothing serialized means no query at all rather than a
bare trailing ?.

Consume the blueprint's isNullable flag in the codegen: a nullable
param is typed string|NullValue|null and accepts the sentinel, while a
merely optional one keeps ?string and rejects it, so the type system
catches sending an accidental null where it would unset a value.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HMKbYgbsoFdKiiaxXB6jh2
@razor-x
razor-x force-pushed the claude/php-beta-pr-1d60h3 branch from abae20d to 351c862 Compare August 13, 2026 19:26
Mirror seamapi/python#617: the new StrictUrlSearchParamsSerializer
wraps the base serializer and appends _strict=true to any non-empty
query, telling the Seam API to use strict, schema-aware parsing. The
flag is appended after the sort so it always sits last, a
caller-supplied _strict param is replaced rather than repeated, and a
query with no serializable params stays empty.

The flag is Seam API behavior, not part of the serialization standard,
so it is isolated in the wrapper and the base UrlSearchParamsSerializer
stays a pure implementation of the standard. The SDK client serializes
every request with the strict wrapper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HMKbYgbsoFdKiiaxXB6jh2
@razor-x
razor-x force-pushed the claude/php-beta-pr-1d60h3 branch from 618b945 to d75e355 Compare August 14, 2026 04:26
Keep a comment only when it says something the code cannot: a
non-obvious why, an external constraint, or an invariant a future edit
would break. Comments that narrate a test, restate an assertion, or
argue the code is correct are deleted; the tests are the explanation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HMKbYgbsoFdKiiaxXB6jh2
@razor-x
razor-x force-pushed the claude/php-beta-pr-1d60h3 branch from 8f02093 to d694874 Compare August 14, 2026 04:46
claude added 2 commits August 14, 2026 05:59
Setting a param to null becomes a top-level usage section and
Serializing URL search params follows the structure of the Python and
Ruby READMEs, including the note explaining why PHP spells the
sentinel NullValue::NULL where the other SDKs spell it NULL with type
Null: both names are reserved in PHP, so the type and the value live
on one enum.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HMKbYgbsoFdKiiaxXB6jh2
Drop the UTF-16 sort key: a stable sort is what the standard needs to
keep array element order, and byte order matches URLSearchParams.sort()
for every ASCII name, which all Seam param names are. Only a name
beyond the Basic Multilingual Plane could order differently than the
reference implementation, and then only against a name in U+E000 to
U+FFFF.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HMKbYgbsoFdKiiaxXB6jh2
@razor-x
razor-x merged commit ac1789b into beta Aug 14, 2026
15 checks passed
@razor-x
razor-x deleted the claude/php-beta-pr-1d60h3 branch August 14, 2026 06:05
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.

2 participants