Getting started

Authentication

The PHP SDK authenticates with an API key and nothing else. How scopes, project fences and the workspace binding actually behave.

The SDK authenticates with an API key and nothing else. There is no login flow, no device authorisation, no browser round-trip, and no dependency on the CLI. Pass a key, or set ENVLESS_TOKEN, and you are done, which is exactly what makes it usable from CI, a cron job, an Artisan command, a queue worker or a provisioning script.

php
use Envless\Envless;

$envless = new Envless(token: (string) getenv('ENVLESS_PROVISIONING_TOKEN'));

echo $envless->me->get()['workspaceId'], PHP_EOL;

The envless binary, which ships as @goenvless/cli, has its own device-based authentication for interactive human use. The two are independent: an API key never needs the CLI, and the CLI's device session is not something the SDK can consume.

A key is bound to one workspace

A key is bound to a single workspace at creation time, permanently. The workspace comes from the key, never from the request, so there is no header or argument that points a key at a different one.

$envless->workspaces->list() is the single exception to that boundary: it reads the key owner's memberships, so it can name workspaces the key cannot act on. current set to true marks the bound one.

php
use Envless\Envless;

$envless = new Envless();

foreach ($envless->workspaces->iterate() as $workspace) {
    $reach = $workspace['current'] ? 'this key' : 'needs its own key';

    echo $workspace['slug'], ' (', $reach, ')', PHP_EOL;
}

$envless->workspaces->create() succeeds but the new workspace is not reachable with the key that created it. Mint a key inside it before you can add anything.

Scopes

Every endpoint except me->get() requires a scope. A scope is a resource and a level:

Resourceread grantswrite grants
workspaceRead the workspace and its domainRename, toggle Virtual Envs, manage the domain, delete
productsList and read productsCreate, update, delete, duplicate
product-accessRead per-product grantsGrant and revoke them
projectsList and read projectsCreate, update, delete, duplicate
environmentsList and read environmentsCreate, update, delete, duplicate, expose
variablesRead variables and historyCreate, update, delete, apply
versionsList versions and entriesPublish, roll back, re-encrypt entries
membersList members and invitesInvite, re-role, remove, leave
rolesRead roles and permissionsCreate, update, delete custom roles
keysList and read keysMint and revoke keys
webhooksRead endpoints and deliveriesCreate, update, rotate, redeliver

A missing scope is 403 auth.scope_missing, and resource on the exception names exactly what was needed:

php
use Envless\Envless;
use Envless\Exception\ApiException;

$envless = new Envless();
$passphrase = (string) getenv('ENVLESS_PASSPHRASE');
$workspaceId = $envless->me->get()['workspaceId'];
$flag = Envless::encryptValue('true', $passphrase, $workspaceId);

try {
    $envless->variables->create('billing', 'production', [
        'name' => 'FLAG',
        'value' => $flag,
    ]);
} catch (ApiException $error) {
    if (!$error->isScopeMissing()) {
        throw $error;
    }

    echo 'This key needs ', $error->resource, PHP_EOL;
}

Some methods need two scopes because they touch two resources. versions->rollback() rewrites variables, so it needs versions:write and variables:write. Each method page lists its own requirement.

Scopes are clamped twice

A key never carries more authority than the person who created it.

Against the minting key

A key cannot mint a key with scopes it does not itself hold. That attempt is 403 auth.scope_ceiling_exceeded.

Against the owner role

What survives is clamped again to the workspace role of the key's owner. A viewer cannot mint a writing key even from a key that holds variables:write.

So read the response back

keys->create() returns the scopes you were actually granted, which may be less than you asked for. Never assume the request body was honoured.

php
use Envless\Constants\ScopeLevels;
use Envless\Constants\ScopeResources;
use Envless\Envless;

$envless = new Envless();

$key = $envless->keys->create([
    'name' => 'ci-deploy',
    'scopes' => [
        ScopeResources::VARIABLES => ScopeLevels::READ,
        ScopeResources::VERSIONS => ScopeLevels::READ,
    ],
]);

echo $key['secret'], PHP_EOL;

foreach ($key['scopes'] as $resource => $level) {
    echo $resource, ':', $level, PHP_EOL;
}

secret is shown once and is unrecoverable, and scopes is what you were actually granted.

Project fences

A key can be fenced to specific projects with projectIds. Any request touching a project outside the fence fails with 403 auth.key_project_restricted.

php
use Envless\Envless;

$envless = new Envless();
$billing = $envless->projects->get('billing');

$key = $envless->keys->create([
    'name' => 'billing-only',
    'scopes' => ['variables' => 'read'],
    'projectIds' => [$billing['id']],
]);

echo $key['secret'], PHP_EOL;

An empty projectIds list means the whole workspace. Private products add a second, independent gate: the key owner also needs a grant on the product, or the call is 403 auth.product_restricted.

The fence is not only a filter. Several workspace-level surfaces are closed to a fenced key outright, whatever scope it holds, because none of them can be expressed as a subset of projects:

A fenced key cannot read the key list. keys->list(), keys->get(), keys->update() and keys->delete() all fail with 403 auth.key_project_restricted. keys->create() is the only key method it keeps, and there projectIds becomes mandatory and must be a non empty subset of the caller's own fence.

The same refusal applies to every webhooks method, to every products write (create, update, delete, duplicate), to both products->access writes (grant, revoke), and to projects->create() and projects->duplicate(), since a newly created project could not be covered by the fence.

What a fenced key still reads at workspace level is me, workspace, workspaces, workspace->domain, members, invites, roles, and the product reads (products->list(), products->get(), products->access->list()). Those carry no project dimension, so there is nothing for the fence to narrow. projects->list() and products->listProjects() are narrowed rather than closed: they return only the fenced projects.

Rotation

There is deliberately no rotate method for API keys. Rotation is create, cut over, delete:

php
use Envless\Envless;

$envless = new Envless();
$current = $envless->keys->get('0191f3c2-8a71-7c3e-9b2d-4f5a6c7d8e90');

$replacement = $envless->keys->create([
    'name' => 'ci-deploy-2026-10',
    'scopes' => $current['scopes'],
]);

echo $replacement['secret'], PHP_EOL;

$envless->keys->delete($current['id']);

Deleting a key takes effect immediately with no grace period. Deploy the replacement secret to your runner and confirm it works before you delete the old one.

Environment variables

VariableRead byUsed for
ENVLESS_TOKENnew Envless(), Envless::init() and Envless::getClient()The API key used when no token: is passed
ENVLESS_API_ENDPOINTThe same threeOverrides the base URL, for a self-hosted or local instance
ENVLESS_DISABLE_UPDATE_NOTICEEvery client constructionAny non empty value silences the check for a newer version on Packagist

Those three are the whole list, and an explicit argument always wins over the variable. Each one is read from $_ENV, then $_SERVER, then getenv(), so a value your framework's dotenv loader put in $_ENV counts too. This is one place the PHP SDK differs from TypeScript: there, new Envless() reads no environment at all.

The update check only runs on the command line, once per process and only when standard output is a terminal, so a web request never makes it. Passing disableUpdateNotice: true silences it without an environment variable.

A token is trimmed of surrounding whitespace, the way an HTTP header would be, and a token with a control character or anything outside visible ASCII is refused with Envless\Exception\InvalidArgumentException before any request is made. The error never echoes the token, and neither do var_dump() and print_r() of a client, which show [redacted] in its place.