boci/hetzner-laravel
Laravel package that integrates Hetzner Cloud into your app, offering a fluent API to manage servers, volumes, networks, SSH keys, and other resources. Simplifies provisioning, scaling, and automating infrastructure from Laravel with clean, expressive methods.
Install and configure
composer require boci/hetzner-laravel
php artisan vendor:publish --tag="hetzner-config" # tag name may differ; check the service provider
Add the token to .env:
HETZNER_API_TOKEN=your-project-scoped-token
Generate the token in the Hetzner Cloud Console under the project (not the account) → Security → API tokens. Note that Hetzner Cloud tokens and Hetzner DNS tokens are issued from different places and are not interchangeable — if you use both, expect two config keys.
Where to look first
src/ root — the facade/entry class and the service provider. That tells you whether it's a Saloon SDK, a PendingRequest wrapper, or something custom.Servers, Networks, LoadBalancers, Dns, …). This is your real API reference; the README is usually thinner than the code.tests/ — SDK repos almost always contain the most accurate usage examples, better than the docs.The package is pre-1.0 with 0 dependents, so treat method names in any blog post (including this one) as indicative and confirm signatures in the source before writing a provisioning script.
First use case: read-only inventory, not provisioning
Start with something that cannot cost money or delete data. A scheduled command that lists servers and flags anything without backups enabled, or reports which servers were created outside your IaC pipeline:
use Boci\HetznerLaravel\Facades\Hetzner;
$servers = Hetzner::servers()->all(); // may auto-paginate — verify
foreach ($servers as $server) {
if (! $server->backupWindow) {
$this->warn("{$server->name} has no backups");
}
}
Get comfortable with pagination, error shapes, and rate limiting on reads before you let this package create anything.
Wrap the facade behind your own service. Don't scatter Hetzner:: calls through controllers. Bind a small HetznerInventory / HetznerProvisioner class in a service provider, inject it, and keep the SDK as an implementation detail. This is what makes swapping to raw HTTP calls (see Gotchas) a one-file change, and it gives you a seam for Http::fake() in tests.
Separate read paths from write paths. Reads can live in HTTP requests behind a short cache. Writes — create server, delete volume, attach load balancer — belong in queued jobs or artisan commands with --dry-run and confirmation flags. A web request that creates a €50/month server because someone double-clicked is the classic failure mode.
Use labels as your identity system, not names. Hetzner names are not a reliable unique key, and users rename things. Stamp every resource you create:
Hetzner::servers()->create([
'name' => "web-{$tenant->id}-{$env}",
'labels' => [
'managed-by' => 'laravel',
'tenant' => (string) $tenant->id,
'env' => $env,
],
// ...
]);
Then reconcile by label selector rather than name. This makes cleanup scripts safe and makes "what does this app own?" answerable without a database table that drifts.
Treat mutations as asynchronous. Hetzner returns an action object for most mutations. The server exists before it's running. Build one waitForAction($action, $timeout) helper with backoff and use it everywhere; do not sprinkle sleep(30) through jobs. Poll with a bounded timeout and throw a domain exception on expiry so the queue retries visibly rather than silently hanging.
Reconcile with a scheduled command. Because Hetzner Cloud has no general-purpose webhook/event push for your app, the durable pattern is a scheduled reconciler:
Schedule::command('hetzner:reconcile')->everyFiveMinutes()
->withoutOverlapping()->onOneServer();
The command compares desired state (your DB/config) to actual state (API), then issues the minimum set of mutations. Reconcilers are idempotent by construction, which matters a lot when an API call succeeds but the response times out.
Cache aggressively on reads, invalidate on writes. Inventory calls are slow relative to a page render. Cache for 30–60 seconds keyed by resource type and any filter args, and forget the cache inside your write wrappers — not in the controller.
Multi-project / multi-token. If you manage several Hetzner projects, instantiate one client per token rather than mutating global config at runtime. A named-client registry (hetzner()->project('staging')) avoids the "wrong project" class of bug, which is unrecoverable once a delete lands.
Testing. Fake the transport, not your service layer: Http::fake([...]) if the package sits on Laravel's HTTP client, or the package's own fake/mock hook if it exposes one. Store a few real response fixtures from the Hetzner API docs as tests/fixtures/*.json and assert your DTO mapping once — that's the part most likely to break on a package upgrade.
Hetzner DNS and Hetzner Cloud are separate APIs with separate tokens and separate hosts. If the package covers both, config almost certainly has two credential keys. Mixing them up produces 401s that look like a bad token rather than the wrong token.
No fine-grained token permissions. A Hetzner token is read/write for the whole project. There's no "only servers" scope. Consequence: any process holding the token can delete your production database server. Keep provisioning credentials out of web-facing runtime config where possible, and never echo the token into a DTO you return as JSON (check whether toArray() on the client/config leaks it).
Billing leaks are silent. Deleting a server does not delete its volumes, primary IPs, or load balancers. Your teardown path must enumerate and remove dependent resources explicitly, or you'll pay for orphans for months. Write the teardown script before the provisioning script.
Rate limits and 429s. Hetzner rate-limits per project. A reconciler looping over hundreds of resources will hit it. Catch the package's rate-limit exception, honour Retry-After, and back off — don't let a 429 bubble into a failed job that retries immediately and makes it worse.
Pagination defaults are small. If all() is a single page, you will silently operate on 25 resources out of 400. Verify whether the method auto-paginates or exposes ->paginate() / cursor helpers; assert count in a test.
Timeouts on queue workers. Set an explicit connect/request timeout in the config or via the underlying client. A default infinite-ish Guzzle timeout will pin a worker for minutes when Hetzner has a bad day.
SSH keys must already exist in the project before you reference them by name or ID on server creation. Bootstrap that as a separate idempotent step; failures here are confusing because the error is about a key you "obviously" have locally.
Cache staleness after writes. If your read path is cached and your write path doesn't bust it, dashboards show deleted servers for a TTL. Centralise invalidation in the write wrapper.
Escape hatch. When the package lags the API (likely, given the release cadence), check whether it exposes its underlying HTTP client, e.g. Hetzner::client()->post('servers', $payload) or Saloon's ->connector(). If it does, prefer that over forking the package — you keep upgrades. If it doesn't, and you're relying on this for destructive operations, reconsider: 91 stars and 0 dependents means very little battle-testing, so read the delete/detach code paths in src/ yourself before running them against production.
Pin the version and read the changelog before upgrading. Pre-1.0 SDKs rename methods between minors. A composer update that renames all() to list() is an outage if it's in a scheduled reconciler that fails loudly.
Extension points to look for: a macroable facade, a custom connector/client resolver binding, and the config's HTTP options array. Those three cover most "the package doesn't do X" situations without a fork.
How can I help you explore Laravel packages today?