Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Workos Php Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

Minimal Setup

  1. Installation (the package is framework-agnostic — there is no Laravel service provider to auto-discover):
    composer require workos/workos-php
    
  2. Configuration — add credentials to 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
    
  3. Bind a singleton in 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'),
    ));
    
  4. Fail fast — v9.x no longer validates credentials at construction time, so a misconfigured deploy only fails on the first API call. Guard in the binding:
    abort_if(blank(config('services.workos.api_key')), 500, 'Missing WORKOS_API_KEY');
    
  5. Pin an exact minor while you upgrade. v9.5.0 contains a second 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.

Where to Look First

  • 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.
  • Read the spec-regen PRs, not just the changelog. Entries listed as "Changed errors for endpoint X" mean the documented error catalogue for that endpoint moved; they are not cosmetic.

First Use Case: Authenticate an AuthKit Session in Middleware

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.


Implementation Patterns

Client Lifecycle

// 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::class over app(WorkOS::class) inside domain code; it keeps the constructor signature honest and lets you swap in a fake in feature tests.

Session Management & AuthKit

// 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.

SSO Flow (controller pair)

// 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.

Admin Portal Links (revamped in v9.5.0)

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.

User Management, Roles & Provenance

$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.

Agents Blueprints & 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.

Pipes / Data Integrations

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.

Directory Sync

$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.

Error Handling

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.

Testing

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.


Gotchas and Tips

Breaking / Behavioural Changes in v9.5.0

  1. Pipes upsert request models removed (hard break)

    • Gotcha: 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.
    • Fix: Migrate to DataIntegrationsUpsertApiKeyRequest / DataIntegrationsUpsertClientCredentialsRequest (and ReauthorizeDataIntegrationsUpsertApiKeyRequest), which now carry account_display_name and account_identifier.
    • Tip: grep -rn "AddDataIntegrationsUpsert" app/ tests/ before bumping. Also check connection_owner handling — the enum variants those models owned no longer exist.
  2. Idempotency-key contract changed on four POST endpoints

    • Gotcha: 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.
    • Fix: Generate one key per logical operation, store it with the operation row, and reuse it verbatim across retries; never derive it from mutable state (timestamps, attempt counters).
    • Tip: Assert in a test that a duplicated call with the same key does not create a second connected account.
  3. ConnectedAccount gains established_by_user_id and state_reason — and can be unhealthy

    • Gotcha: A connected account can exist while not being usable. If you treat "row exists" as "integration works", you'll ship confusing failures.
    • Fix: Persist and surface state_reason; gate token vending on a healthy state, and show established_by_user_id in admin views so you know who to ask.
    • Tip: Backfill these columns nullable and treat null as "unknown / pre-9.5.0 legacy" rather than "healthy".
  4. ConnectedAccountWriteResponseWarning means 2xx ≠ success

    • Gotcha: Write responses can now include warnings. Silently discarding them hides degraded connections.
    • Fix: Inspect warnings on every create/upsert and log plus surface them.
    • Tip: Treat an empty-warnings check as part of your success path, not an error path.
  5. Error catalogues changed across audit logs, authorization, feature flags, organizations and organization membership

    • Gotcha: "Changed errors for endpoint X" is not cosmetic — the documented error set for ~20 endpoints moved. Code that string-matched $e->getMessage() or assumed a specific code will silently mis-handle failures.
    • Fix: Branch on HTTP status and typed exceptions, never on message text. Re-read the error docs for any audit-log/authorization endpoint you depend on.
    • Tip: Add getRequestId() to your default exception context so a status-code mismatch is diagnosable from a single log line.
  6. UserRoleAssignmentSource is now a model carrying group

    • Gotcha: Provenance used to be a plain string on the assignment. It now has structure (group → UserRoleAssignmentSourceGroup), so === 'AUTO_PROVISIONING' style checks break or silently return false.
    • Fix: Read sourceType for the enum and source->group for the IdP group; guard with property_exists() / instanceof during rollout since cached payloads predate the field.
    • Tip: Re-warm any cached user/role payloads; stale serialised assignments won't have group.
  7. Admin portal link errors changed

    • Gotcha: 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.
    • Fix: Re-check the endpoint's error docs, and validate external_user_id before sending (it round-trips to your app — a typo becomes a support ticket).
  8. invocable_as is required-ish config, not decoration

    • Gotcha: Blueprints created before v9.5.0 have no invocable_as; new create/update requests expect it as a typed enum. Guessing the value at call sites spreads inconsistency.
    • Fix: Store the value with the blueprint, expose it in your admin UI, and validate against AgentBlueprintInvocableAsInstanceTypes before dispatch.
  9. Directory sync can now 429

    • Gotcha: POST /directories/{id}/sync with an explicit typed rate-limit error model. Naive retry loops will hammer it.
    • Fix: Back off, and only trigger from queued jobs with a unique job constraint per connection.
  10. Spelling-normalisation chore touched the codebase

    • Gotcha: 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.
    • Fix: Run 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.

Carried-Over Behavioural Changes from v9.4.0 (still in force)

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. Pipes ownership governs which endpoints are legal — persist ownership and connection_owner and branch on it before choosing org-scoped vs user-scoped endpoints.

  6. Agents sessions are org-scoped — always pass organizationId: from tenant context, or you get a confusing cross-org view.

  7. Static configuration is gone — WorkOS::setApiKey() no longer validates; construct via the container with named arguments and validate presence at boot.

  8. 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');
    
  9. 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.

Debugging Tips

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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".

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
teamzac/geocoder
ideatica/config-writer
keaton-php/pipe-dream
jacksonwebservices/laravel-user-suite
maksimru/conditional-laravel-debugbar
gismatul/nova-support-chat
gismatul/laravel-support-chat
networkrailbusinesssystems/oracle-api
alphabalex/messaging-made-easy
rawphp/warp
shootkiran/merobug
narakode/fineauth
numencode/wn-syncops-plugin
ibnnajjaar/mib-global-pay
ably/pubsub-server
mstfkhazaal/defender
mfonte/base62x
karnoweb/metapilot
tobya/bcsapiwrapper
swalker2/cpanel