workos/workos-php
The official WorkOS PHP library gives PHP applications convenient access to the WorkOS API. Install via Composer, configure WORKOS_API_KEY and WORKOS_CLIENT_ID, and integrate SSO, Directory Sync, Admin Portal, Magic Link, and more.
composer require workos/workos-php
config/services.php and .env rather than calling env() at runtime (config caching will return null for env() outside config files):
// config/services.php
'workos' => [
'api_key' => env('WORKOS_API_KEY'),
'client_id' => env('WORKOS_CLIENT_ID'),
'issuer' => env('WORKOS_ISSUER'), // used for optional `iss` validation
'base_url' => env('WORKOS_API_BASE_URL', 'https://api.workos.com'),
],
WORKOS_API_KEY=sk_test_...
WORKOS_CLIENT_ID=client_...
WORKOS_ISSUER=https://your-app.com
AppServiceProvider::register() so you get one client per request lifecycle and a single place to swap credentials per tenant:
use WorkOS\WorkOS;
$this->app->singleton(WorkOS::class, fn () => new WorkOS(
apiKey: config('services.workos.api_key'),
clientId: config('services.workos.client_id'),
baseUrl: config('services.workos.base_url'),
));
abort_if(blank(config('services.workos.api_key')), 500, 'Missing WORKOS_API_KEY');
feat(generated)! spec regen (#452) hot on the heels of v9.4.0. Pin "workos/workos-php": "9.5.0", upgrade in a dedicated PR, then loosen the constraint.WorkOS\WorkOS — the entry point; every service hangs off it (sso(), userManagement(), sessionManager(), pipes(), directorySync(), adminPortal(), agents(), userManagementAuthkitOAuthResources(), …).src/Service/ — one class per API surface; method signatures map 1:1 to the OpenAPI spec, so the WorkOS docs reference is the real API documentation.src/Model/ and src/Enum/ — the generated response objects and enums (PipesOwnership, CreateDataIntegrationOwnership, AgentBlueprintInvocableAs, DataIntegrationsUpsertApiKeyRequest, …). Read these to know which properties are nullable and which are brand new.src/Exception/ — WorkOSException base plus AuthenticationException, and AccountSelectionRequiredError.The most common day-to-day job in a Laravel app is turning an incoming bearer token into a user. The JWT must carry a valid exp, and you should pin the issuer:
// app/Http/Middleware/AuthenticateWorkOS.php
use WorkOS\WorkOS;
use WorkOS\Exception\AuthenticationException;
public function handle($request, Closure $next)
{
try {
$session = app(WorkOS::class)->sessionManager()->authenticate(
sessionToken: $request->bearerToken(),
issuer: config('services.workos.issuer'), // rejects foreign issuers
);
} catch (AuthenticationException $e) {
return response()->json(['error' => 'unauthenticated'], 401);
}
$request->attributes->set('workos_user', $session->user);
return $next($request);
}
Session auth is untouched in v9.5.0 — if this middleware is your only integration, the upgrade is a no-op for you.
// Singleton (recommended) — bound in AppServiceProvider, injected everywhere
public function __construct(private WorkOS $workos) {}
// Per-tenant / per-request client — useful for multi-tenant SaaS where each
// tenant stores its own WorkOS credentials
$workos = new WorkOS(
apiKey: $tenant->workos_api_key,
clientId: $tenant->workos_client_id,
);
// Testing — always override baseUrl so you can point at a fake
$this->app->instance(WorkOS::class, new WorkOS(
apiKey: 'test_key',
clientId: 'test_client',
baseUrl: 'https://workos.test',
));
Prefer injecting
WorkOS::classoverapp(WorkOS::class)inside domain code; it keeps the constructor signature honest and lets you swap in a fake in feature tests.
// 1. Validate an incoming session JWT
$session = $workos->sessionManager()->authenticate(
sessionToken: $request->bearerToken(),
issuer: config('services.workos.issuer'),
);
// $session->user->id, $session->user->email, ...
// 2. Passwordless: session IDs are URL-encoded in send URLs.
// Treat the returned URL as opaque — redirect, never rebuild.
$magic = $workos->userManagement()->createMagicAuth(
email: '[email protected]',
redirectUri: route('auth.callback'),
);
return redirect()->away($magic->sendUrl); // do NOT urlencode/decode or reassemble
authenticate() requires exp. If you proxy or re-sign tokens yourself you must preserve the claim; when you mint tokens for local testing, always include exp and iss.
// GET /auth/sso — start
public function redirect(WorkOS $workos)
{
return redirect()->away($workos->sso()->getAuthorizationUrl(
redirectUri: route('sso.callback'),
domain: 'your-org.workos.com',
state: json_encode(['return_to' => url()->previous()]),
));
}
// GET /auth/sso/callback — finish
public function callback(Request $request, WorkOS $workos)
{
$state = json_decode($request->query('state', '{}'), true);
$token = $workos->sso()->getProfileAndToken(
code: $request->query('code'),
clientSecret: config('services.workos.api_key'), // API key doubles as client secret
);
Auth::loginUsingId(
User::firstOrCreate(['email' => $token->profile->email])->id
);
return redirect($state['return_to'] ?? '/dashboard');
}
TokenQuery.code and SSOLogoutAuthorizeResponse.logout_url changed format in the v9.4 regen — if you persisted a logout_url from an older version, regenerate it rather than migrating it.
You can now tie a portal link to a user and revoke it:
use WorkOS\WorkOS;
// Generate — `externalUserId` is new in v9.5.0
$link = $workos->adminPortal()->generateLink(
intent: 'sso',
organizationId: $org->workos_id,
externalUserId: (string) $user->id, // your app's user ID, round-trips to you
);
return redirect()->away($link->link); // opaque — never parse or rewrite the host
// Revoke (new endpoint POST /portal/revoke_link)
$workos->adminPortal()->revokeLink(/* model: RevokeLink */);
Store issued links against the user and org so you can revoke on offboarding or role change. Note the error contract for POST /portal/generate_link also changed in this release — see Gotchas.
$page = $workos->userManagement()->listUsers(limit: 50, after: $cursor);
foreach ($page->autoPagingIterator() as $user) {
foreach ($user->roleAssignments as $assignment) {
$assignment->source; // provenance — model in the regenerated spec
$assignment->sourceType->value; // enum — read ->value when serialising
// v9.5.0: `group` added to UserRoleAssignmentSource, backed by the
// new UserRoleAssignmentSourceGroup model (IdP group → role mapping)
$group = $assignment->source?->group;
$group?->id;
$group?->name;
}
}
// Origin/redirect auditing (handy as an artisan command or health check)
$corsOrigins = $workos->userManagement()->getCorsOrigins();
$redirectUris = $workos->userManagement()->getRedirectUris();
// Step-up auth
$result = $workos->userManagement()->authorize(userId: $user->id, maxAge: 3600);
Because source may now be an object rather than a bare string, code that did === 'AUTO_PROVISIONING' needs to move to sourceType. Use the group payload to render "granted because you're in eng-platform" in admin UIs — it answers the support question before it's asked.
invocable_as (new in v9.5.0)Blueprints now declare what can invoke them, not just what they do:
use WorkOS\Enum\AgentBlueprintInvocableAs;
$blueprint = $workos->agents()->createBlueprint(
name: 'Support triage',
invocableAs: AgentBlueprintInvocableAs::/* instance type enum */,
);
$workos->agents()->updateBlueprint(
blueprintId: $blueprint->id,
invocableAs: AgentBlueprintInvocableAs::/* ... */,
);
invocable_as is a typed contract on AgentBlueprint, AgentBlueprintsCreateRequest and AgentBlueprintsUpdateRequest, with matching ...InvocableAsInstanceTypes enums. Persist the value alongside the blueprint so you can gate invocation in your own job dispatch rather than discovering a mismatch at call time. Also remember agent sessions are org-scoped — always pass organizationId: from tenant context.
The conceptual model: an integration can be owned by an organization, a user, or nobody, and a single user may have multiple connected accounts for one provider.
use WorkOS\Enum\PipesOwnership;
// 1. Scope the list by ownership
$integrations = $workos->pipes()->listDataIntegrations(
ownership: PipesOwnership::ORGANIZATION,
);
// 2. Vend a token when the user has MULTIPLE linked GitHub accounts
$token = $workos->pipes()->getUserToken(
slug: 'github',
userId: $user->id,
connectedAccountId: 'conn_acct_123',
supportsMultipleConnections: true,
connectionOwner: 'organization',
);
// 3. Organization-scoped integration CRUD
$workos->pipes()->getOrganizationDataIntegration(slug: 'github');
$workos->pipes()->deleteOrganizationDataIntegration(slug: 'github');
Model this in your schema as users ← connected_accounts → integrations rather than a single user.provider_token column.
Connected-account identity fields (new in v9.5.0) — account_display_name and account_identifier are now writable on every create/upsert input (CreateConnectedAccount, CreateOrganizationConnectedAccount, ConnectedAccountInput, DataIntegrationsCreateApiKeyConnectionRequest, DataIntegrationsUpsertApiKeyRequest, ReauthorizeDataIntegrationsUpsertApiKeyRequest, DataIntegrationsUpsertApiKeyRequest2) and readable on ConnectedAccount, OrganizationConnectedAccount, ConnectedAccountWriteResponse and DataIntegrationsListResponseDataConnectedAccount:
// Write with human-meaningful identity
$response = $workos->pipes()->createConnectedAccount(
// ... slug, user/org scope ...
accountDisplayName: 'Acme GitHub (ops)',
accountIdentifier: 'acme-ops',
);
// Read back
$account->accountDisplayName; // "Acme GitHub (ops)"
$account->accountIdentifier; // "acme-ops" — your key for dedupe/matching
$account->establishedByUserId; // who linked it (new)
$account->stateReason; // why it is not healthy (new)
This is the missing piece for the AccountSelectionRequiredError UX: you can render an account picker from the list response alone, labelled with account_display_name, without an extra round trip or your own mapping table.
Write responses can carry warnings (new in v9.5.0) — ConnectedAccountWriteResponseWarning means a 2xx is not the same as "fully healthy":
$response = $workos->pipes()->/* create or upsert connected account */;
foreach ($response->warnings ?? [] as $warning) {
Log::warning('WorkOS connected account warning', ['warning' => $warning]);
// surface to the user: "your key was saved but the connection is degraded"
}
Idempotency contract changed (v9.5.0) for POST /data-integrations/{slug}/api-key, POST /data-integrations/{slug}/client-credentials, POST /organizations/{organization_id}/connected_accounts/{slug} and POST /user_management/users/{user_id}/connected_accounts/{slug}. Anything that relied on the old retry/dedupe semantics needs revisiting — see Gotchas.
$status = $workos->directorySync()->getStatus();
try {
$result = $workos->directorySync()->triggerSync(
connectionId: $connection->workos_id,
syncType: 'full', // POST /directories/{id}/sync
);
} catch (WorkOSException $e) {
// v9.5.0 adds a typed DirectorySyncRateLimitError / DirectorySyncResponse pair —
// branch on the status code (429) or the typed error, depending on how your
// version surfaces it, and back off rather than retrying immediately.
if ($e->getStatusCode() === 429) {
$this->release(60);
return;
}
throw $e;
}
Trigger syncs from a queued job, never a request cycle — the endpoint is rate limited and is the new, obvious candidate for a 429 in production.
use WorkOS\Exception\{WorkOSException, AuthenticationException};
use WorkOS\Exception\AccountSelectionRequiredError;
try {
$token = $workos->pipes()->getUserToken(slug: 'github', userId: $user->id);
} catch (AccountSelectionRequiredError $e) {
// The user has >1 connected account for this provider and you didn't
// pass connected_account_id. Prompt them to pick, then retry.
return redirect()->route('integrations.choose', ['slug' => 'github']);
} catch (AuthenticationException $e) {
return redirect()->route('login');
} catch (WorkOSException $e) {
Log::error('WorkOS failure', [
'message' => $e->getMessage(),
'status' => $e->getStatusCode(),
'request_id' => $e->getRequestId(), // always log this — support asks for it
]);
throw new \RuntimeException('Identity provider unavailable');
}
AccountSelectionRequiredError is the explicit signal for the "one user, many connected accounts" case — handle it rather than guessing at a connected_account_id.
public function test_rejects_session_token_without_expiration(): void
{
$this->expectException(AuthenticationException::class);
app(WorkOS::class)->sessionManager()->authenticate(
sessionToken: $this->jwt(claims: ['sub' => 'user_123']) // no `exp`
);
}
public function test_rejects_foreign_issuer(): void
{
$this->expectException(AuthenticationException::class);
app(WorkOS::class)->sessionManager()->authenticate(
sessionToken: $this->jwt(claims: ['exp' => time() + 300, 'iss' => 'https://evil.test']),
issuer: config('services.workos.issuer'),
);
}
public function test_connected_account_warnings_are_logged(): void
{
// Fake at the WorkOS boundary and return a ConnectedAccountWriteResponse
// carrying a ConnectedAccountWriteResponseWarning, then assert the
// warning reaches your logger / user-facing notice.
}
Because the client is bound as a singleton, $this->app->instance(WorkOS::class, $fake) in setUp() gives you clean isolation across the suite. Add at least one test per Pipes write path before upgrading — those are the endpoints whose request models and idempotency contract moved in v9.5.0.
Pipes upsert request models removed (hard break)
AddDataIntegrationsUpsertApiKeyRequest, AddDataIntegrationsUpsertClientCredentialsRequest and their ...ConnectionOwner enums are gone. Code that type-hinted or constructed them fatals on load — not at the API call, at autoload.DataIntegrationsUpsertApiKeyRequest / DataIntegrationsUpsertClientCredentialsRequest (and ReauthorizeDataIntegrationsUpsertApiKeyRequest), which now carry account_display_name and account_identifier.grep -rn "AddDataIntegrationsUpsert" app/ tests/ before bumping. Also check connection_owner handling — the enum variants those models owned no longer exist.Idempotency-key contract changed on four POST endpoints
POST /data-integrations/{slug}/api-key, POST /data-integrations/{slug}/client-credentials, POST /organizations/{organization_id}/connected_accounts/{slug} and POST /user_management/users/{user_id}/connected_accounts/{slug} changed their idempotency-key contract. Retry logic that was safe before the upgrade can now double-write — or start colliding on a reused key.ConnectedAccount gains established_by_user_id and state_reason — and can be unhealthy
state_reason; gate token vending on a healthy state, and show established_by_user_id in admin views so you know who to ask.null as "unknown / pre-9.5.0 legacy" rather than "healthy".ConnectedAccountWriteResponseWarning means 2xx ≠ success
warnings. Silently discarding them hides degraded connections.warnings on every create/upsert and log plus surface them.Error catalogues changed across audit logs, authorization, feature flags, organizations and organization membership
$e->getMessage() or assumed a specific code will silently mis-handle failures.getRequestId() to your default exception context so a status-code mismatch is diagnosable from a single log line.UserRoleAssignmentSource is now a model carrying group
group → UserRoleAssignmentSourceGroup), so === 'AUTO_PROVISIONING' style checks break or silently return false.sourceType for the enum and source->group for the IdP group; guard with property_exists() / instanceof during rollout since cached payloads predate the field.group.Admin portal link errors changed
POST /portal/generate_link now documents a different error set, plus external_user_id is a new accepted input. Old error handling around link generation may no longer match reality.external_user_id before sending (it round-trips to your app — a typo becomes a support ticket).invocable_as is required-ish config, not decoration
invocable_as; new create/update requests expect it as a typed enum. Guessing the value at call sites spreads inconsistency.AgentBlueprintInvocableAsInstanceTypes before dispatch.Directory sync can now 429
POST /directories/{id}/sync with an explicit typed rate-limit error model. Naive retry loops will hammer it.Spelling-normalisation chore touched the codebase
use American spelling and add agent spelling rule (#455) may have renamed identifiers, docblocks, or enum string values in the generated surface. Cheap to miss, fatal at runtime.composer show -a workos/workos-php-free diff review of src/Enum/ between 9.4.x and 9.5.x before bumping. Any renamed enum string value is an API-level change, not a lint fix.JWT expiration is mandatory — sessionManager()->authenticate() rejects tokens without a valid exp. Don't strip exp when proxying or re-minting. Add an explicit missing-exp test so a future regen doesn't silently relax it.
Issuer validation is opt-in — no issuer: argument means no iss check, so a staging or other-tenant token validates fine. Always pass issuer: config('services.workos.issuer') and assert it's non-null at boot.
Passwordless session IDs are URL-encoded — redirect()->away($response->sendUrl) and nothing else. Never urldecode(), trim(), or case-fold any returned code, URL, secret, or credential.
Widespread opaque-field format changes — MagicAuth.code, EmailVerification.code, PasswordReset.password_reset_url, Invitation.accept_invitation_url, PortalLinkResponse.link, AgentAdminValidateCredentialRequest.credential, OrganizationApiKeyWithValue.value, AuthenticationFactorEnrolledTotp.secret/.qr_code/.uri, SSOLogoutAuthorizeResponse.logout_url, and more. Regenerate fixtures, snapshots and queued jobs holding these values; don't migrate old ones.
Pipes ownership governs which endpoints are legal — persist ownership and connection_owner and branch on it before choosing org-scoped vs user-scoped endpoints.
Agents sessions are org-scoped — always pass organizationId: from tenant context, or you get a confusing cross-org view.
Static configuration is gone — WorkOS::setApiKey() no longer validates; construct via the container with named arguments and validate presence at boot.
Named arguments, always — the optional-parameter surface keeps growing (connectedAccountId, ownership, supportsMultipleConnections, connectionOwner, organizationId, accountDisplayName, accountIdentifier, externalUserId, invocableAs). Positional args bind to the wrong slot silently.
// Correct
$workos->userManagement()->createUsers(email: '[email protected]', firstName: 'John');
// Wrong — a signature change can shift `lastName` into `emailVerified`
$workos->userManagement()->createUsers('[email protected]', 'John');
PHP 8.2+ and the 8.x → 9.x jump — v9 changed signatures and formats across nearly every service. Pin an exact minor, bump in a dedicated PR, run the suite plus a staging smoke test of SSO, magic auth, session auth, and Pipes connected-account writes.
Tap Guzzle to see every request
$stack = \GuzzleHttp\HandlerStack::create();
$stack->push(\GuzzleHttp\Middleware::tap(function ($request) {
Log::debug('WorkOS →', [
'method' => $request->getMethod(),
'url' => (string) $request->getUri(),
'headers' => $request->getHeaders(), // redact Authorization before prod logs
'body' => (string) $request->getBody(),
]);
}));
new WorkOS(apiKey: '...', clientId: '...', handler: $stack);
For the v9.5.0 Pipes writes, also log the idempotency key header — it's the fastest way to prove whether a duplicate row came from your retry logic or the API.
Decode the JWT locally before calling authenticate()
Split on ., base64url-decode the payload (diagnostics only, never trust it), and log whether exp and iss are present. This instantly explains the "valid token now rejected" class of bug.
Log getRequestId() on every failure
WorkOS support can trace a request from the ID alone. Add it to your default exception handler context so you never have to reproduce a failure to get one.
Watch for double-encoding
If a magic link, invitation URL, or portal link 404s after an upgrade, dump the URL as returned and compare with what your app redirected to. A difference in % sequences means something in your stack re-encoded it.
Diff src/Model/ and src/Enum/ between tags
For a spec-regen release, git diff 9.4.0..9.5.0 -- src/Model src/Enum is more accurate than the changelog: removals are breaking, additions are optional, and renamed properties are the silent killers. Run it before bumping, not after staging breaks.
Verify the version you actually deployed
Run composer show workos/workos-php in the container — with a major bump behind a caret constraint, a stale composer.lock in one environment is a common source of "works locally, fails in staging".
How can I help you explore Laravel packages today?