tabuna/vesta-api
Laravel/PHP client for the Vesta Control Panel API. Manage users, domains, DNS records, mail accounts, databases, and backups from your application through a clean, expressive wrapper with a simple, consistent interface.
Install the client and wire up credentials in three steps:
composer require tabuna/vesta-api:^2.0
php artisan vendor:publish --provider="Tabuna\VestaAPI\Providers\VestaServiceProvider"
Then populate .env:
VESTA_HOST=panel.example.com
VESTA_PORT=8083
VESTA_USER=admin
VESTA_PASSWORD=secret
VESTA_SSL=true
The package exposes a facade (Tabuna\VestaAPI\Facades\Vesta) and an injectable client. Your first use case is almost always read-only: verify connectivity and your credentials before attempting writes.
use Tabuna\VestaAPI\Facades\Vesta;
// Smoke test: list users on the panel
$response = Vesta::user()->list();
if ($response->failed()) {
report($response->message());
}
Before you write any code, check the installed version's composer.json constraints. Release 2.0 is advertised as "Updated to Laravel 7x" — a framework-compatibility bump, not a feature release — so the illuminate/* and php requirements in the package are the first thing to reconcile against your app's composer.json. If you're on Laravel 9+, plan for a fork (see Gotchas) before promising a delivery date.
Look first at:
config/vesta.php – host/port/user/password and SSL toggle.user(), domain(), database(), dns(), etc.) – each maps 1:1 to a VestaCP shell command (v-list-users, v-add-domain, …).successful() / failed() plus the raw JSON body.Provision a customer in one flow. Chain the section helpers to mirror the steps you'd take in the VestaCP UI. Keeping the sequence under a single try/catch means the whole provisioning op is atomic enough for most use cases:
use Tabuna\VestaAPI\Facades\Vesta;
try {
Vesta::user()->add([
'username' => $username,
'password' => $password,
'email' => $email,
'package' => 'default',
]);
Vesta::domain()->add([
'user' => $username,
'domain' => $domain,
'ip' => '203.0.113.10',
]);
Vesta::database()->add([
'user' => $username,
'database' => $database,
'dbuser' => $dbUser,
'dbpass' => $dbPass,
]);
} catch (\Throwable $e) {
// compensate: roll back the user so retries are clean
Vesta::user()->delete(['username' => $username]);
throw $e;
}
Wrap the client behind your own service. Because the package is small and its return shapes are thin, don't let raw Vesta::… calls leak into controllers or jobs. Create a HostingProvisioner class that depends on the client, translate the package's response into your own result/value objects, and inject it. That single seam makes it trivial to swap in a direct HTTP client later if (when) you need to fork or replace the package — and since 2.0 shipped no API changes, that seam is still your only real upgrade insurance.
Push slow work onto the queue. v-add-domain, v-rebuild-web-domains, and DNS changes are real shell commands with timeouts. Dispatch a ProvisionDomainJob, call the client inside it, and let retries + backoff() handle transient panel hiccups. Do not run these in a synchronous request handler.
Listen to outcomes. Fire a domain event (DomainProvisioned, PanelCommandFailed) from your service wrapper so other parts of the app – mail setup, certificate issuance, billing state – react without a hard dependency on VestaCP.
Bulk data: Vesta::user()->list() and friends return the panel's dataset as an array. Cache it (Cache::remember('vesta.users', 600, …)) rather than polling the panel from dashboards; VestaCP isn't built for high query volume and rate-limiting you is entirely plausible.
Read before write. Before deleting or suspending, re-query the object. VestaCP has no transactions – if your app thinks a user exists when it doesn't, you'll get opaque status codes back from the API instead of a clear 404.
2.0 is a compatibility bump, not a revival. The release notes consist of a single line: "Updated to Laravel 7x." That tells you two things. First, the previously documented "last release April 2020" is now out of date — there is a maintained-ish release line again, so ^2.0 is the version to pin rather than 1.x. Second, the ceiling is Laravel 7: Laravel 7 itself reached end-of-life in 2021, and there is no indication this package supports Laravel 9/10/11/12. If your app is on ≥ 9, you are still forking. Check composer require output against your lockfile — dependency resolution, not the changelog, is the authoritative compatibility statement.
It's a major version bump with no documented breaking changes. Semver says 1.x → 2.0 may break you; the release notes say nothing about the public API. Diff the tagged source against your installed 1.x (or run your test suite against ^2.0 in a branch) instead of trusting the single-line note. The most likely real change is a raised illuminate/* floor plus associated type-hint/return-type adjustments — cheap to absorb, expensive to discover in production.
VestaCP's API is not REST. It answers everything with 200 OK and encodes the real outcome in a status value inside the body (negative/1 for failure, positive/0 for success depending on the command). Always check ->successful()/->failed() on the response object – never rely on HTTP codes. This behaviour is a property of the panel, not the package, so 2.0 does not change it.
TLS on port 8083 breaks silently. VestaCP ships with a self-signed cert. In older Guzzle you could set verify => false; here you control that via config (VESTA_SSL). For production, install a real cert on the panel or set verify => '/path/to/ca.pem'. Silently disabling verification "just to get it working" is how you end up shipping an unauthenticated admin channel.
Credentials with shell-special characters in .env. VestaCP passwords regularly contain $, #, or backticks. Laravel's .env parser will happily mangle those – quote them (VESTA_PASSWORD="p@ss\$word") and confirm the value your app actually sees (config('vesta.password')) before blaming the API.
Return shapes are the panel's, not yours. Command output is a JSON blob keyed by arbitrary field names that differ per command (v-list-user vs v-list-database). Type them yourself in your service layer; don't spread $response['data']['DOMAIN'] style access across your codebase.
Error messages are lossy. A failed v-add-domain often comes back as just "status": -2 or a raw shell error string. Map codes to friendly errors in your wrapper, and log the full response body (Log::debug(json_encode($response))) at failure time – you'll need it later.
Idempotency is on you. VestaCP has no upsert; adding an existing domain returns an error. Guard writes with an existence check (Vesta::domain()->list() filtered) or catch the specific "already exists" status and treat it as success in your provisioner.
Extension point of last resort: if a command isn't wrapped, drop to the raw client's generic request()/command() method and pass the VestaCP verb (v-add-cron-job, etc.) plus its arguments. That's also your escape hatch when a wrapper method has a bug — the 2.0 notes don't claim bug fixes, so the raw call remains the reliable path for anything the wrappers get wrong.
Still audit before using in a security-critical, multi-tenant flow. A Laravel 7 compatibility bump is not a security review. The package is a thin wrapper over Guzzle (~200 lines); read the credential handling and request signing yourself, or fork it. That remains the cheapest long-term answer, and 2.0 doesn't change the calculus — it just means your fork now has a fresher starting point.
How can I help you explore Laravel packages today?