chuckbartowski / proxmox-sdk
PHP SDK for the Proxmox VE API with typed clients, high-level modules and task helpers
Requires
- php: >=8.2
- symfony/http-client: ^6.4|^7.0
- symfony/http-client-contracts: ^3.0
Requires (Dev)
- phpunit/phpunit: ^10.5|^11.0
- symfony/config: ^6.4|^7.0
- symfony/dependency-injection: ^6.4|^7.0
- symfony/http-kernel: ^6.4|^7.0
Suggests
- symfony/config: To register ProxmoxSdkBundle in a Symfony application
- symfony/dependency-injection: To register ProxmoxSdkBundle in a Symfony application
- symfony/http-kernel: To register ProxmoxSdkBundle in a Symfony application
README
π§ Proxmox VE SDK for PHP
A modern, fully typed PHP SDK for the Proxmox VE API β QEMU, LXC, cluster, storage, backups.
QEMU Β· LXC Β· Cluster Β· Storage Β· Backups Β· Network Β· Access control Β· Task helpers
Installation Β· Quick Start Β· Tasks (UPID) Β· API Reference
$vmid = (int) $proxmox->cluster()->nextId()->data; $task = $proxmox->qemu()->clone('pve1', 9000, $vmid, ['name' => 'web01', 'full' => 1]); $proxmox->nodes()->waitForTask('pve1', $task->upid()); $proxmox->qemu()->start('pve1', $vmid);
Framework-agnostic core β usable from any PHP project, script, or worker β with an optional bundle for first-class Symfony integration. Authenticated with API tokens or ticket/cookie credentials, typed exceptions, async task helpers, and a comment-free, strictly typed codebase (PHP 8.2+, declare(strict_types=1) everywhere).
Table of Contents
- Features
- Requirements
- Installation
- Quick Start (plain PHP)
- Symfony Integration (optional)
- Architecture
- Authentication
- Asynchronous Tasks (UPID)
- API Reference
- Responses
- Error Handling
- Testing
- Security Notes
- WHMCS module
- License
Features
- Wide API coverage: nodes, QEMU VMs, LXC containers, cluster, storage, backup jobs, vzdump, networking, users/tokens/ACLs.
- Both auth modes: API tokens (recommended, stateless) and ticket/cookie login with automatic re-login on expiry and CSRF handling for write requests.
- Task-aware: Proxmox returns a UPID for every asynchronous operation β the SDK detects them (
$response->upid()) and can block until completion (waitForTask()), failing loudly when the task itself failed. - QEMU and LXC share one polished interface: identical lifecycle methods (start, stop, clone, migrate, snapshots, resizeβ¦) implemented once, specialized where the APIs differ.
- Framework-agnostic: one plain facade (
Proxmox) you can instantiate anywhere; the only hard dependency issymfony/http-client, a standalone component that works in any PHP project. - A single normalized response object (
ApiResponse) that unwraps Proxmox'sdataenvelope and flattens field-level validation errors. - Typed exception hierarchy under one marker interface, so you can catch narrowly or broadly.
- Nothing is sealed off: the client's
get/post/put/deleteaccept any path, so an endpoint not wrapped by a module is one call away. - Optional Symfony bundle with semantic configuration and autowirable services.
- Fully unit-tested against
MockHttpClient(no network required).
Requirements
| Dependency | Version |
|---|---|
| PHP | >= 8.2 |
| Proxmox VE | 7.x or 8.x (API tokens require 6.2+) |
| Symfony | 6.4 LTS or 7.x β optional, only for the bundle integration |
Installation
The package is published on Packagist:
composer require chuckbartowski/proxmox-sdk
Quick Start (plain PHP)
No framework required β build the client and go:
use ChuckBartowski\ProxmoxSdk\Client\ProxmoxClient; use ChuckBartowski\ProxmoxSdk\Proxmox; $proxmox = new Proxmox(new ProxmoxClient( host: 'pve.example.com', tokenId: 'automation@pve!sdk', tokenSecret: getenv('PVE_TOKEN_SECRET'), )); $vmid = (int) $proxmox->cluster()->nextId()->data; $task = $proxmox->qemu()->clone('pve1', 9000, $vmid, ['name' => 'web01', 'full' => 1]); $proxmox->nodes()->waitForTask('pve1', $task->upid()); $proxmox->qemu()->start('pve1', $vmid);
Client constructor signature:
new ProxmoxClient( string $host, string $tokenId = '', // 'user@realm!tokenname' (preferred) string $tokenSecret = '', string $username = '', // fallback: ticket auth string $password = '', string $realm = 'pam', // appended when $username has no '@' int $port = 8006, bool $verifySsl = true, float $timeout = 30.0, ?HttpClientInterface $httpClient = null, // inject your own (retries, proxy, mockβ¦) );
Symfony Integration (optional)
Register the bundle:
// config/bundles.php return [ ChuckBartowski\ProxmoxSdk\ProxmoxSdkBundle::class => ['all' => true], ];
Then create config/packages/proxmox_sdk.yaml:
proxmox_sdk: host: '%env(PVE_HOST)%' token_id: '%env(PVE_TOKEN_ID)%' token_secret: '%env(PVE_TOKEN_SECRET)%' verify_ssl: true
# .env.local PVE_HOST=pve.example.com PVE_TOKEN_ID=automation@pve!sdk PVE_TOKEN_SECRET=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Configuration reference
| Key | Type | Default | Description |
|---|---|---|---|
host |
string | required | Proxmox VE hostname (no scheme, no port) |
token_id |
string | '' |
Full token id user@realm!tokenname (preferred) |
token_secret |
string | '' |
Token secret UUID |
username / password |
string | '' |
Ticket auth pair, used when no token is configured |
realm |
string | pam |
Realm appended to username when it has no @ |
port |
int | 8006 |
API TLS port |
verify_ssl |
bool | true |
TLS peer/host verification (see Security Notes) |
timeout |
float | 30.0 |
Per-request timeout in seconds |
The Proxmox facade is then autowirable in controllers, services, commands, and message handlers. The bundle reuses your application's http_client service when available and falls back to a native client otherwise.
Architecture
src/
βββ ProxmoxSdkBundle.php Symfony bundle: config tree + service wiring (optional)
βββ Proxmox.php Facade: entry point for all modules
βββ Client/
β βββ ProxmoxClient.php Transport, token/ticket auth, CSRF, JSON handling
βββ Response/
β βββ ApiResponse.php Immutable normalized response (+ upid() helper)
βββ Exception/
β βββ ProxmoxSdkExceptionInterface.php
β βββ ApiException.php API answered but reported a failure (or a task failed)
β βββ AuthenticationException.php
β βββ TransportException.php Network / TLS / timeout / invalid JSON
βββ Api/
βββ AbstractApi.php AbstractGuestApi.php (shared QEMU/LXC lifecycle)
βββ NodeApi.php QemuApi.php LxcApi.php ClusterApi.php
βββ StorageApi.php BackupApi.php NetworkApi.php AccessApi.php
Design decisions:
- Facade + lazy modules:
Proxmoxinstantiates each module on first use and caches it. - Modules always validate: every module method calls
ensureSuccess()internally and throwsApiExceptionon failure. To inspect a failed response without an exception, drop down to the client level. - One guest abstraction:
AbstractGuestApiimplements the shared VM/container lifecycle;QemuApiadds what only VMs have (hard reset, guest agent, VNC proxy). - Ticket state is self-healing: on a 401/403 with ticket auth, the client discards the ticket, re-logs once, and retries the request transparently.
Authentication
Two modes, checked in order:
- API token (recommended) β stateless, no CSRF, survives restarts. Create one in Datacenter Β» Permissions Β» API Tokens (or via this SDK):
$proxmox->access()->createToken('automation@pve', 'sdk', ['privsep' => 1]);
Configure with tokenId: 'automation@pve!sdk' + the secret shown once at creation. Grant the token its own permissions when privsep is enabled β a common pitfall: a privilege-separated token has no permissions until you add ACLs for it.
- Ticket (username/password) β the SDK logs in against
/access/ticket, stores the ticket and CSRF token in memory, sendsPVEAuthCookieon every call andCSRFPreventionTokenon writes, and re-logs automatically when the ticket expires (~2 hours).
Missing credentials throw an AuthenticationException immediately, before any network request.
Asynchronous Tasks (UPID)
Most mutating operations (start, clone, migrate, vzdump, destroyβ¦) return immediately with a task identifier (UPID:node:β¦) while the work continues server-side:
$task = $proxmox->qemu()->clone('pve1', 9000, 101, ['full' => 1]); $task->upid(); // 'UPID:pve1:...:qmclone:9000:root@pam:' $proxmox->nodes()->waitForTask('pve1', $task->upid(), timeout: 600.0, pollInterval: 2.0);
waitForTask() polls the task status until it stops, returns the final status on success (OK or warnings), and throws an ApiException carrying the real exitstatus when the task failed β so a failed clone never masquerades as a success. taskLog() fetches the task output for diagnostics.
API Reference
Every method returns an ApiResponse and throws on failure (see Error Handling).
Nodes
$proxmox->nodes()
| Method | Endpoint |
|---|---|
list() |
GET /nodes |
status(string $node) |
GET /nodes/{node}/status |
reboot(string $node) / shutdown(string $node) |
POST /nodes/{node}/status |
version(string $node) |
GET /nodes/{node}/version |
services(string $node) / restartService(string $node, string $service) |
GET/POST /nodes/{node}/services⦠|
tasks(string $node, array $filters = []) |
GET /nodes/{node}/tasks |
taskStatus(...) / taskLog(...) / stopTask(...) |
GET/DELETE /nodes/{node}/tasks/{upid}β¦ |
waitForTask(string $node, string $upid, float $timeout = 300.0, float $pollInterval = 1.0) |
polling helper |
QEMU virtual machines
$proxmox->qemu() β full VM lifecycle on /nodes/{node}/qemu.
| Method | Notes |
|---|---|
list(node) / create(node, vmid, options) / remove(node, vmid, purge: bool) |
purge: true also removes unreferenced disks and job entries |
config(node, vmid) / updateConfig(node, vmid, options) |
|
currentStatus(node, vmid) |
|
start / stop / shutdown / reboot / reset / suspend / resume |
all return a UPID |
clone(node, vmid, newid, options) |
['full' => 1] for a full clone, template linked clones otherwise |
migrate(node, vmid, target, options) |
['online' => 1] for live migration |
resize(node, vmid, disk, size) |
e.g. ('scsi0', '+10G') |
snapshots / createSnapshot / deleteSnapshot / rollbackSnapshot |
|
agentPing(node, vmid) / agentExec(node, vmid, command) |
QEMU guest agent |
vncProxy(node, vmid) |
websocket-enabled console ticket |
$proxmox->qemu()->create('pve1', 101, [ 'name' => 'web01', 'memory' => 4096, 'cores' => 2, 'net0' => 'virtio,bridge=vmbr0', 'scsi0' => 'local-lvm:32', 'ide2' => 'local:iso/debian-12.iso,media=cdrom', ]);
LXC containers
$proxmox->lxc() β the same lifecycle interface as QEMU (minus VM-only operations) on /nodes/{node}/lxc.
$proxmox->lxc()->create('pve1', 200, [ 'ostemplate' => 'local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst', 'hostname' => 'app01', 'memory' => 1024, 'rootfs' => 'local-lvm:8', 'net0' => 'name=eth0,bridge=vmbr0,ip=dhcp', 'unprivileged' => 1, ]);
Cluster
$proxmox->cluster() β version(), status(), resources(?type) (filter: vm, storage, node, sdn), tasks(), nextId(), options() / setOptions(), haResources(), and resource pools: pools(), pool(poolId), createPool(poolId, ?comment), updatePool(poolId, fields) (add/remove vms/storage members, delete => 1 to remove), deletePool(poolId).
nextId() + create() is the standard provisioning pattern shown in the Quick Start.
$proxmox->cluster()->createPool('customers'); $proxmox->cluster()->updatePool('customers', ['vms' => '101,102']);
Storage
$proxmox->storage() β cluster-wide definitions (list, create, update, remove) and per-node views: nodeStorages(node), status(node, storage), content(node, storage, ?contentType), deleteVolume(...), and downloadUrl(node, storage, url, filename) to pull ISOs/templates straight from a URL (returns a UPID).
Backups
$proxmox->backups() β scheduled job management on /cluster/backup (jobs, createJob, updateJob, deleteJob) and on-demand dumps: run(node, options) (POST /nodes/{node}/vzdump) and defaults(node).
$proxmox->backups()->run('pve1', ['vmid' => '101', 'storage' => 'backup-nfs', 'mode' => 'snapshot', 'compress' => 'zstd']);
Network
$proxmox->network() β node network interfaces: list, find, create(node, iface, type, options) (types: bridge, bond, vlan, β¦), update, remove, plus apply(node) to activate pending changes and revert(node) to discard them. Proxmox stages network changes β nothing is live until apply().
Access control
$proxmox->access() β users (users, createUser, updateUser, deleteUser), API tokens (tokens, createToken, deleteToken), groups, roles(), and ACLs: acl(), updateAcl(path, roles, options) (add ['delete' => 1] to revoke), permissions(?path, ?userid).
$proxmox->access()->createUser('deploy@pve', ['password' => 'S3cret!', 'groups' => 'automation']); $proxmox->access()->updateAcl('/vms/101', 'PVEVMAdmin', ['users' => 'deploy@pve']);
Responses
All calls return an immutable ApiResponse; the Proxmox data envelope is already unwrapped:
$response = $proxmox->cluster()->resources('vm'); $response->success; // bool $response->statusCode; // int $response->data; // mixed β the payload's data section $response->data('version'); // keyed access with optional default $response->errors; // list<string> β field errors flattened as 'field: message' $response->raw; // complete decoded payload (envelope included) $response->upid(); // task id string when the call started an async task, null otherwise
Error Handling
All SDK exceptions implement ProxmoxSdkExceptionInterface, so a single catch covers everything:
| Exception | Thrown when | Extras |
|---|---|---|
ApiException |
The API answered but reported a failure, or an awaited task failed | getErrors(), getStatusCode(), getRaw() |
AuthenticationException |
Credentials are missing, login failed, or 401/403 persisted after re-login | thrown before any request when credentials are empty |
TransportException |
Network error, TLS failure, timeout, or a non-JSON response body | wraps the underlying symfony/http-client exception |
use ChuckBartowski\ProxmoxSdk\Exception\ApiException; use ChuckBartowski\ProxmoxSdk\Exception\ProxmoxSdkExceptionInterface; try { $task = $proxmox->qemu()->clone('pve1', 9000, $vmid, ['full' => 1]); $proxmox->nodes()->waitForTask('pve1', $task->upid()); } catch (ApiException $e) { $this->logger->error('VM provisioning failed', ['errors' => $e->getErrors()]); } catch (ProxmoxSdkExceptionInterface $e) { throw new ProvisioningUnavailableException(previous: $e); }
To inspect a failed response without exceptions, use the client directly β client-level methods return the response as-is:
$response = $proxmox->client()->post('/nodes/pve1/qemu', $params); if (!$response->success) { // $response->statusCode, $response->errors, $response->raw }
Testing
The suite runs entirely offline against MockHttpClient:
composer install vendor/bin/phpunit
To test your own services, inject a ProxmoxClient built with a mock:
use ChuckBartowski\ProxmoxSdk\Client\ProxmoxClient; use ChuckBartowski\ProxmoxSdk\Proxmox; use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\Response\JsonMockResponse; $http = new MockHttpClient(new JsonMockResponse(['data' => []])); $proxmox = new Proxmox(new ProxmoxClient('host', tokenId: 't@pve!x', tokenSecret: 's', httpClient: $http));
Security Notes
- Secrets (
tokenSecret,password) are passed with#[\SensitiveParameter], so they never appear in stack traces. - Prefer API tokens with privilege separation (
privsep => 1) and grant them only the ACLs they need (PVEVMAdminon/vms, notAdministratoron/). - A fresh Proxmox VE ships with a self-signed certificate. Rather than disabling
verify_ssl, install a proper certificate (ACME is built into Proxmox) β keepverify_ssl: falsestrictly for lab environments. - Keep credentials in
.env.localor your secret vault β never commit them. remove()on guests (especially withpurge: true),deleteVolume()androllbackSnapshot()are irreversible β gate destructive calls behind confirmation flows in your application.
WHMCS module
A ready-to-use WHMCS provisioning module ships in whmcs/modules/servers/proxmoxsdk/. It provisions VPS by cloning a template through this SDK β clone + wait + start on create, suspend/resume, terminate (stop + purge), and reboot.
Install
composer require chuckbartowski/proxmox-sdkin your WHMCS root.- Copy the
proxmoxsdkfolder into<whmcs>/modules/servers/. - Add a server with Type: Proxmox VE (SDK), the PVE hostname, the token id (
user@realm!name) as username, and the token secret in the Access Hash field. - Set the config options: Node, Template VMID, and VMID base (the new VMID is
base + service id, so it is deterministic and collision-free).
| Operation | SDK call |
|---|---|
| Create | qemu()->clone() β nodes()->waitForTask() β qemu()->start() |
| Suspend / Unsuspend | qemu()->suspend() / resume() |
| Terminate | qemu()->stop() β remove(purge: true) |
| Reboot | qemu()->reboot() |
License
MIT