Skip to content

feat: unify the SDK with the Seam SDKs for other languages - #460

Open
razor-x wants to merge 11 commits into
mainfrom
claude/php-sdk-unification-vyv0yj
Open

feat: unify the SDK with the Seam SDKs for other languages#460
razor-x wants to merge 11 commits into
mainfrom
claude/php-sdk-unification-vyv0yj

Conversation

@razor-x

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

Copy link
Copy Markdown
Member

Brings the runtime surface of this SDK up to date. The codegen and release tooling were already in place; the client, auth, error and test layers were not.

Added

  • Personal access token authentication, with the seam-workspace header, plus token format validation that rejects a client session token, JWT or publishable key with a specific message instead of letting the server return an opaque 401.
  • from_api_key, from_personal_access_token and from_client factories.
  • Seam\SeamWithoutWorkspace for the endpoints that take no workspace in scope, exposing only workspaces->list() and workspaces->create().
  • Seam\SeamWebhook, verifying incoming webhooks with svix and returning a typed Event.
  • Configuration from the environment: SEAM_API_KEY, SEAM_PERSONAL_ACCESS_TOKEN, SEAM_WORKSPACE_ID and SEAM_ENDPOINT, plus the deprecated SEAM_API_URL and both of its warnings. A client can be constructed with no arguments under either authentication method; defining both credential variables at once is ambiguous and raises an InvalidOptionsError.
  • Retries, twice by default with exponential backoff, via caseyamcl/guzzle_retry_middleware.
  • A 30 second request timeout, covering connecting and reading, configurable with timeout.
  • HTTP layer configuration: guzzle_options, retries, timeout, and an injectable client.
  • A client level wait_for_action_attempt default, taking a bool or a timeout and polling_interval.
  • A test suite covering auth, env, headers, errors, malformed responses, retries, pagination, serialization, action attempts, resources and webhooks, run against @seamapi/fake-seam-connect.
  • Psalm, wired into composer lint.

Nested resource classes

A nested class was named after the root resource plus the property, but the base name was never replaced as the recursion descended, so every shape below the first level competed for one name and the first one generated silently won.

device.properties.battery and device.properties.accessory_keypad.battery both resolved to DeviceBattery. The keypad won, so the device battery was generated with only level and $device->properties->battery->status did not exist. The same collapse hit the climate preset metadata, which took the shape of properties.ecobee_metadata and lost climate_ref, is_optimized and owner.

A nested class is now named after its property alone and declared in the namespace of the class that owns it, so the two batteries are Device\Properties\Battery and Device\Properties\AccessoryKeypad\Battery. This also keeps Seam\Resources free of the hundreds of names that existed only to type a property. Where the old code silently overwrote, codegen now throws: on two siblings producing the same class name, on a name PHP reserves as a type, and on nesting deeper than 16 levels.

Merging a discriminated union moved to codegen/lib/merge-properties.ts, shared verbatim with the Ruby SDK, and unions by name recursively rather than taking the first occurrence. A merged property keeps its description only when every variant that documents it agrees, since each variant documents the property for its own case. This drops the description on is_device_error, which claimed the error is not a device error on every variant including the ones where it is.

Fixed

Bugs found in the existing client along the way:

  • Responses in the 3xx range were treated as successful (the check was >= 400 rather than outside 200–299).
  • The Seam error check accepted any body with a truthy error key. It now requires a JSON content type and string error.type and error.message.
  • throw_http_errors let Guzzle throw before the SDK could map the error, making the entire error mapping block unreachable when set. The option is gone; errors always map.
  • Malformed JSON silently decoded to null and then failed on property access. The try/catch around json_decode was dead code, since it returns null rather than throwing.
  • A non-Seam error response raised an exception built from a fabricated request with a relative URI and no headers. Error mapping is middleware now, so it holds the real one.
  • getRequestId() returned "" rather than null when the header was absent, and the fallback error type was unknown rather than unknown_error.
  • HttpInvalidInputError never actually overrode the error code — it wrote to a dynamic property because the parent's was private — and the action attempt errors wrote to an undeclared $name property, a PHP 8.2 deprecation CI would have hit on 8.5.
  • Paginator::firstPage() indexed its cache unconditionally, and the null cursor guard was unreachable because the parameter was typed non-nullable.

Two more came out of writing the tests: the SEAM_API_KEY environment variable was overriding an explicitly passed personal access token, and Guzzle resolves its handler stack in reverse, so a history middleware pushed by a caller sits outside the retry middleware and cannot observe retries at all, which would have made the retry assertions silently vacuous.

Breaking changes

Covered in the new "Upgrading from 3.x" README section.

  • PHP 8.2 or later is required, and svix/svix is a new dependency. PHP 8.1 reached end of life in December 2025.
  • The client class is Seam\Seam, replacing Seam\SeamClient.
  • The constructor takes named options, so endpoint is no longer the second positional argument, and throw_http_errors is removed.
  • The exception classes stay in the Seam\ namespace and now share a Seam\SeamException interface; InvalidOptionsError and InvalidTokenError are new.
  • poll_until_ready() is removed in favour of wait_for_action_attempt, which also takes a timeout and polling_interval.
  • $seam->client is the Guzzle client, and Seam::request() is removed. Read a body with Seam\Http\Body::decode().
  • The $api_key property and the global LTS_VERSION constant are removed. Use Seam\Seam::LTS_VERSION.
  • A response in the 3xx range is no longer treated as successful.
  • Requests time out after 30 seconds and are retried twice.
  • Pagination metadata is a Seam\Pagination rather than a stdClass.
  • Nested resource classes are namespaced, so Seam\Resources\DeviceBattery is now Seam\Resources\Device\Properties\Battery.

Notes on the diff

  • src/Seam.php is the generated client. PSR-4 requires the file name to match the class, which is why it is not src/SeamClient.php.
  • PHPUnit went 9 → 10 because PHPUnit 9 pins nikic/php-parser v4 while Psalm 6 needs v5. The config was migrated to the 10.5 schema.
  • composer.json now pins config.platform.php, so the lockfile is resolved against the oldest supported PHP rather than whichever version generated it.
  • The CI matrix was 8.0, 8.5; it is now 8.2 through 8.5, so the versions in between are actually exercised.
  • Coverage excludes src/Resources and src/Routes, since measuring coverage on generated code only creates pressure to test the generator.
  • Endpoint methods are documented as taking named arguments. PHP cannot declare a parameter as name-only, and parameter order comes from the API definition, so a positional call can start binding a value to the wrong parameter after an upgrade.

Verification

Against PHP 8.4 locally:

  • composer test — 103 tests, 190 assertions, against a real fake-seam-connect instance
  • composer lintcomposer validate --strict, php -l, and Psalm, all clean
  • npm run lint and npx tsc --noEmit — clean
  • npm run generate twice — the second run produces an empty diff, so the generator stays a fixed point as generate.yml requires
  • Every README snippet run against the fake, including the paginator, the without-workspace client and a webhook verify round trip

CI covers 8.2, 8.3 and 8.5, which this environment could not exercise.

@razor-x
razor-x force-pushed the claude/php-sdk-unification-vyv0yj branch from ff3b407 to c8bb9db Compare August 11, 2026 17:06
razor-x and others added 9 commits August 12, 2026 22:37
The Python, Ruby, and JavaScript SDKs share a runtime core, a common README
skeleton, and a test suite aligned to the same baseline. This SDK had the
codegen and release tooling but not the runtime surface. This brings it in
line.

Added:

- Personal access token authentication, with the seam-workspace header, and
  token format validation that rejects client session tokens, JWTs, and
  publishable keys with a specific message.
- from_api_key, from_personal_access_token, and from_client factories.
- SeamMultiWorkspace for the endpoints that are not scoped to a workspace.
- SeamWebhook, verifying incoming webhooks with svix.
- SEAM_ENDPOINT support, plus the deprecated SEAM_API_URL and its warnings.
- Retries, two by default with exponential backoff, via
  caseyamcl/guzzle_retry_middleware. A request that never reached the server
  is always retried; a status code is only retried for idempotent methods,
  since retrying a POST the server may already have processed could duplicate
  a write. The other Seam SDKs make the same trade.
- HTTP layer configuration: guzzle_options, retries, and an injectable client.
- A client level wait_for_action_attempt default, accepting a bool or a
  timeout and polling_interval.
- A test suite covering auth, env, headers, errors, malformed responses,
  retries, pagination, serialization, action attempts, and webhooks, run
  against @seamapi/fake-seam-connect.
- Psalm, wired into composer lint.

Fixed:

- Responses in the 3xx range were treated as successful.
- The Seam error check accepted any body with a truthy error key. It now
  checks the content type and that error.type and error.message are strings,
  matching the other SDKs.
- throw_http_errors let Guzzle throw before the SDK could map the error,
  making the whole error mapping unreachable. The option is gone.
- Malformed JSON silently decoded to null and then failed on property access.
- Non-Seam error responses raised an exception built from a fabricated
  request rather than the real one.
- getRequestId returned an empty string rather than null when the header was
  absent, and the fallback error type was unknown rather than unknown_error.
- HttpInvalidInputError never actually overrode the error code, and the
  action attempt errors wrote to an undeclared property.
- Paginator::firstPage indexed its cache unconditionally, and the null cursor
  guard was unreachable.

BREAKING CHANGE: The client is Seam\Seam; Seam\SeamClient remains as a
deprecated alias. The constructor takes named options, so endpoint is no
longer the second positional argument, and throw_http_errors is removed.
Exceptions moved to the Seam\Exceptions namespace. poll_until_ready is
removed in favor of wait_for_action_attempt, whose defaults change from
20s/0.4s to 10s/1s. $seam->client is a Seam\Http\SeamHttpClient rather than a
Guzzle client. The $api_key property and the global LTS_VERSION constant are
removed. Responses in the 3xx range are no longer treated as successful.
Requests are now retried. Pagination metadata is a Seam\Pagination object.
PHP 8.1 or later is required, and svix/svix is a new dependency.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
The core public API is small enough to read at a glance, so nesting part of
it under Seam\Exceptions bought organization it does not need.

Keeping the classes where 3.x had them also means existing catch blocks keep
working. Sub-namespacing errors is the more common PHP convention, but the
Python and JavaScript SDKs both export theirs at the package root, so this is
closer to them as well.

The new SeamException marker interface, InvalidOptionsError, and
InvalidTokenError are all that changes for a caller upgrading from 3.x.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
A nested class was named after the root resource plus the property, but the
base name was never replaced as the recursion descended, so every shape below
the first level competed for one name and addClass silently kept whichever was
generated first.

device.properties.battery and device.properties.accessory_keypad.battery both
resolved to DeviceBattery. The keypad won, so the device battery was generated
with only level and $device->properties->battery->status did not exist. The
same collapse hit the climate preset metadata, which took the shape of
properties.ecobee_metadata and lost climate_ref, is_optimized and owner, and
the phone_session credential and entrance metadata pairs.

The flat map is now a recursive tree. A nested class is named after its
property alone and declared in the namespace of the class that owns it, so the
two batteries are Device\Properties\Battery and
Device\Properties\AccessoryKeypad\Battery. Properties reference their nested
classes relatively, letting PHP resolve them from the owning namespace. This
also keeps Seam\Resources free of the hundreds of names that existed only to
type a property.

Where the old code silently overwrote, codegen now throws: on two siblings
producing the same class name, on a name PHP reserves as a type, and on nesting
deeper than 16 levels, which means a cyclic schema rather than a real shape.

Merging a discriminated union moves to codegen/lib/merge-properties.ts, which
unions by name recursively rather than taking the first occurrence, so a merged
class keeps every variant's fields. A merged property keeps its description
only when every variant that documents it agrees, because each variant
documents the property for its own case and that text is not necessarily true
of the class the variants collapse into. Deprecation is now deprecate-if-any,
since first wins could undeprecate a field depending on blueprint ordering.
This drops the description on is_device_error, which claimed the error is not
a device error on every variant including the ones where it is, while
is_bridge_error keeps the text all variants share.

Resource classes are emitted as one braced namespace block per namespace in a
single file per resource, which works because src/Resources is autoloaded by
classmap rather than PSR-4. The resource and property docblock helpers are
indented one level deeper to match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
The merge module was written from the specification rather than copied, so it
had drifted from the Ruby SDK in details that matter for keeping the two in
step: the merged list was not sorted, the path in a list recursion omitted the
[] segment, the format list in the disagreement error was sorted, and the error
messages were worded differently.

Take codegen/lib/merge-properties.ts from seamapi/ruby as it stands, so the two
SDKs share one implementation and a future change to the semantics is a single
diff to port rather than a reconciliation.

Only the sort is observable here: it reorders the properties of the two merged
resources, which reorders the nested classes emitted for them. The class and
namespace sets of ActionAttempt and Event are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
composer.json requires php ^8.1 but set no resolution platform, so the lock was
resolved against whatever PHP generated it. Generated on 8.4, that selected
Symfony 8.x, which requires php >=8.4.1, and composer install then refused the
lockfile on 8.1, 8.2 and 8.3:

  Your lock file does not contain a compatible set of packages.
    - symfony/console is locked to version v8.1.2 ...
    - symfony/console v8.1.2 requires php >=8.4.1 -> your php version
      (8.2.33) does not satisfy that requirement.

Pin config.platform.php to the oldest PHP this package supports so the lock
represents that platform rather than the machine that happened to write it.
Symfony drops to 6.4 LTS; Psalm and PHPUnit are unchanged.

The patch version is 8.1.31 because Psalm 6 requires ~8.1.31 on the 8.1 line.

The Install jobs passed throughout because they synthesize a composer.json and
never read this lockfile, which is why only Test and Lint caught it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
PHP 8.1 reached end of life in December 2025, so the oldest version this
package supports is now 8.2, which has security support through 2026.

Raise the floor in composer.json, move the resolution platform to 8.2.27, the
patch Psalm 6 requires on the 8.2 line, and drop 8.1 from the CI matrices.
Symfony moves up to 7.4 now that 8.2 is the target.

BREAKING CHANGE: PHP 8.2 or later is required.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
PHP cannot declare a parameter as name-only the way Python's keyword-only
marker and Ruby's required keyword arguments do, so the calling convention can
only be documented rather than enforced.

Parameter order comes from the API definition, so an endpoint that gains a
required parameter can reorder the ones already there, and a positional call
then binds a value to the wrong parameter with nothing to catch it. Every
example now passes arguments by name, and the usage section says why.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
The JavaScript SDK renamed this client to SeamHttpWithoutWorkspace and left
SeamHttpMultiWorkspace behind as a deprecated alias, since what the class
actually does is reach the endpoints that take no workspace in scope rather
than several workspaces at once. This SDK is introducing the class now, so it
can start from the current name with no alias to carry.

Seam\SeamMultiWorkspace becomes Seam\SeamWithoutWorkspace, mirroring Seam\Seam
the way the JavaScript name mirrors SeamHttp. The auth helper and the README
section follow, so multi workspace is gone as vocabulary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
$seam->client was a wrapper that had to be unwrapped with get_client() to reach
the Guzzle client underneath. It is now the Guzzle client itself, so anything
Guzzle can do is reachable without a detour, and there is one client object in
the public surface rather than two.

Error mapping moves to Guzzle middleware, which fits better than the wrapper
did: it sits outside the retry middleware, so it only sees the response a
request finally settled on, and it holds the real request rather than a
fabricated one when raising a transport error. Reading the response body moves
to Seam\Http\Body, called by the generated route methods.

The timeout drops from 60 to 30 seconds and becomes an option of its own rather
than something to bury in guzzle_options, alongside retries. It covers
connecting as well as reading.

Seam::request() is gone; use $seam->client->request().

BREAKING CHANGE: $seam->client is now the Guzzle client, so
$seam->client->get_client() no longer exists, and Seam\Http\SeamHttpClient is
replaced by Seam\Http\ClientFactory. Seam::request() is removed. Requests now
time out after 30 seconds rather than 60.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
@razor-x
razor-x force-pushed the claude/php-sdk-unification-vyv0yj branch from 87bbf8e to c13b28d Compare August 12, 2026 22:52
razor-x and others added 2 commits August 12, 2026 22:55
The install check builds the package, requires it from a scratch project and
constructs a client to prove the published archive autoloads. It still named
Seam\SeamClient, which no longer exists, so the check failed on every PHP
version once the alias went away.

Construct Seam\Seam instead, by name, matching how the README documents calls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
…nment

SEAM_PERSONAL_ACCESS_TOKEN and SEAM_WORKSPACE_ID now fall back into place
the way SEAM_API_KEY already did, so a client can be constructed with no
arguments under either authentication method. Defining both credential
variables at once is ambiguous and raises an InvalidOptionsError.

SeamWithoutWorkspace reads SEAM_PERSONAL_ACCESS_TOKEN as well.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
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