PHP SDK

cipi/sdk is the PHP client for the Cipi panel API. From a Laravel app, a Symfony service, or a plain script, one Sanctum token calls the same REST surface as cipi-cli and the control panel: apps, deploys, domains, databases, and the server.

The SDK runs on your machine. It does not SSH into the VPS and it does not shell out to cipi. The server still needs the API package.

Install and enable the panel API first (cipi api), then create a token with cipi api token create. Grant only the abilities this application needs.

Install

bash
composer require cipi/sdk

Requires PHP 8.2+ with ext-curl and ext-json. Laravel is optional. Source: github.com/cipi-sh/sdk (MIT).


Client

Pass the panel origin and the token. Both https://api.example.com and https://api.example.com/api work. The client always calls /api/*.

php
use Cipi\Sdk\Cipi;

$cipi = Cipi::connect(
    baseUrl: 'https://api.example.com',
    token: getenv('CIPI_TOKEN') ?: '',
);

$apps = $cipi->apps()->list();

Reads return the JSON body as an associative array. Creates, edits, deploys, SSL, and most database writes return 202 with job_id. Poll that id, or let wait() do it.

php
$job = $cipi->deploys()->start('shop');
$result = $cipi->wait($job);

Plain http:// is rejected. For a panel on your laptop, opt in:

php
$cipi = Cipi::connect('http://127.0.0.1:8080', $token, [
    'allow_http' => true,
]);

Laravel

The service provider is discovered automatically on Laravel 11, 12, and 13. Publish the config if you want it in your app:

bash
php artisan vendor:publish --tag=cipi-config
dotenv
CIPI_BASE_URL=https://api.example.com
CIPI_TOKEN=
CIPI_TIMEOUT=30
CIPI_ALLOW_HTTP=false
php
use Cipi\Sdk\Laravel\Facades\Cipi;

$apps = Cipi::apps()->list();

You can also type-hint Cipi\Sdk\Cipi in a controller or job. The container holds one server. For a second server, call Cipi::connect() with that server's URL and token — do not reuse the facade.

Symfony, Slim, and other frameworks use the same Cipi::connect() call and register the instance in their own container. There is no extra bundle.


Apps

App creates accept the same fields as POST /api/apps: user and domain are required. Add repository and branch for Git, php, custom, octane, engine, or the Node fields node, framework, node_version, build, start, output, and health_path.

php
$cipi->apps()->list();
$cipi->apps()->get('shop');

$cipi->wait($cipi->apps()->create([
    'user' => 'shop',
    'domain' => 'shop.example.com',
    'repository' => 'git@github.com:acme/shop.git',
    'branch' => 'main',
    'php' => '8.4',
]));

$cipi->apps()->update('shop', ['php' => '8.4']);
$cipi->apps()->suspend('shop');
$cipi->apps()->unsuspend('shop');
$cipi->apps()->fixPermissions('shop');
$cipi->apps()->recreateWebhook('shop', rotateSecret: true);
$cipi->apps()->delete('shop');

HTTP Basic Auth, Artisan, and whitelisted app commands:

php
$cipi->basicAuth()->enable('shop', 'admin');
$cipi->basicAuth()->disable('shop');

$cipi->wait($cipi->artisan()->run('shop', 'migrate --force'));
$cipi->run()->commands();
$cipi->wait($cipi->run()->execute('shop', 'composer install --no-dev'));

artisan() and run() are async jobs. The panel rejects tinker and interactive programs. logs()->get($app, $type, $page, $perPage) reads nginx, php, worker, deploy, or laravel (or omit the type for all).


Deploy and jobs

php
$cipi->wait($cipi->deploys()->start('shop'));
$cipi->deploys()->rollback('shop');
$cipi->deploys()->unlock('shop');
$cipi->deploys()->audit('shop', days: 30);

$cipi->deployConfig()->get('shop');
$cipi->deployConfig()->update('shop', [
    'keep_releases' => 5,
    'migrate' => true,
]);

wait() polls until completed or failed. The default timeout is 300 seconds. A failed job throws Cipi\Sdk\Exception\JobFailedException and keeps the job payload on $e->job. Pass a job id or the 202 body:

php
$cipi->jobs()->get('9f1c…');
$cipi->jobs()->wait($job, timeoutSeconds: 120, intervalSeconds: 1.0);

Deploy config edits the structured Deployer options and regenerates deploy.php. It does not upload a raw PHP file. The audit call is synchronous and reads the hash-chained ledger (Cipi 5.4.0+).


Domains and SSL

php
$cipi->aliases()->add('shop', 'www.shop.example.com');
$cipi->www()->forceToRoot('shop');

$cipi->redirects()->set('shop', 'https://example.com', 301, keepPath: true);
$cipi->redirects()->add('shop', '/blog/', 'https://blog.example.com', 301, true);
$cipi->redirects()->remove('shop', '/blog/');

$cipi->proxies()->add(
    'shop',
    prefix: '/api/',
    upstream: 'http://127.0.0.1:3000',
    stripPrefix: true,
);

$cipi->ssl()->install('shop');
$cipi->ssl()->force('shop');

Redirect and proxy writes are synchronous on the panel: Nginx is tested and the previous vhost is restored if the test fails. The proxy loopback guard cannot be bypassed from the API. Node apps use node()->runtimes(), node()->status($app), and node()->restart($app) (restart is a job).


Env, auth, and databases

env() merges keys. It does not replace the file. authJson() is the shared Composer auth.json, not HTTP Basic Auth. Both need Cipi 5.0.3+ and the matching token ability (apps-env, apps-auth).

php
$cipi->env()->update('shop', [
    'APP_ENV' => 'production',
], ['TELESCOPE_ENABLED']);

$cipi->authJson()->create('shop');
$cipi->authJson()->replace('shop', [
    'http-basic' => [
        'repo.example.com' => ['username' => 'token', 'password' => $secret],
    ],
]);
php
$cipi->databases()->engines();
$cipi->databases()->list('pgsql');
$cipi->wait($cipi->databases()->create('shop', 'pgsql'));
$cipi->databases()->backup('shop');
$cipi->databases()->restore('shop', '/home/cipi/backups/shop.sql.gz');
$cipi->databases()->password('shop');

engine is mariadb or pgsql. Omit it to use the server default. Backup, restore, and password regeneration are jobs. The restore path has to be a .sql.gz file already on the server.


Server

php
$cipi->server()->status();
$cipi->php()->list();
$cipi->php()->install('8.4');
$cipi->ssh()->add($publicKey);
$cipi->ssh()->remove(2);
$cipi->services()->list();
$cipi->services()->restart('php8.4-fpm');

$cipi->smtp()->get();
$cipi->health()->update('shop', 'https://shop.example.com/health', 200);
$cipi->health()->check('shop');
$cipi->search()->enable('shop');

$cipi->packages()->list();
$cipi->monitor()->list();
$cipi->zeroTrust()->status();

Packages, the system monitor, and Cloudflare Zero Trust are read-only here, matching the panel API. Installing those pieces stays on the host CLI.

The API allowlist defaults to *. Restrict it without locking yourself out — ensure_client_ip: true keeps the caller on the list:

php
$cipi->ipWhitelist()->replace(['203.0.113.10'], ensureClientIp: true);
$cipi->ipWhitelist()->add('203.0.113.11');
$cipi->ipWhitelist()->allowAll();

An endpoint this release does not wrap yet is still reachable:

php
$cipi->request('GET', '/apps/shop');

Security

  • HTTPS and certificate verification are on. Redirects are not followed, so the Bearer token stays on the host you configured.
  • The token is sent only in the Authorization header. A value that contains whitespace or control characters is rejected, which blocks header injection.
  • The token is stripped from exception messages, decoded bodies, and var_dump / print_r output.
  • GET may retry 429 and 503 when Retry-After is at most 10 seconds. Writes are never retried.
  • Responses larger than 16 MiB are refused. Timeouts default to 30 seconds (10 seconds to connect).

The third argument of Cipi::connect() changes those limits. On Laravel the package config reads CIPI_TIMEOUT, CIPI_CONNECT_TIMEOUT, CIPI_MAX_RETRIES, and CIPI_ALLOW_HTTP. A value out of range throws before any request is sent.

php
$cipi = Cipi::connect($url, $token, [
    'timeout' => 30,                  // 1–300 seconds
    'connect_timeout' => 10,          // 1–60 seconds
    'max_retries' => 2,               // 0–5, reads only
    'max_response_bytes' => 16777216, // 1 KiB–64 MiB
    'allow_http' => false,
]);

Keep CIPI_TOKEN in the environment of the PHP host. Do not commit it. Create one token per application, with the smallest ability set that still works (apps-view, deploy-manage, dbs-create, …). A missing ability is HTTP 403, not a silent no-op.


Errors

HTTP failures throw Cipi\Sdk\Exception\CipiException or a subclass. The status, the JSON body, the method, and the path are on the exception. The token is not.

Status Exception
401 AuthenticationException
403 AuthorizationException — missing ability, or IP whitelist
404 NotFoundException
409 ConflictException
422 ValidationException — errors() when the panel returned a Laravel bag
429 RateLimitException
5xx ServerException

Without an HTTP status: TransportException when the connection or the TLS handshake fails, or a response is over the size limit; ConfigurationException for an invalid URL, token, path, or option, before anything is sent. wait() adds JobFailedException when the job ends failed and TimeoutException when it is still running at the timeout. RateLimitException carries retryAfter in seconds when the panel sent it.

php
use Cipi\Sdk\Exception\AuthorizationException;
use Cipi\Sdk\Exception\JobFailedException;

try {
    $cipi->wait($cipi->deploys()->start('shop'));
} catch (JobFailedException $e) {
    $job = $e->job;
} catch (AuthorizationException $e) {
    // token is missing deploy-manage
}