renoki-co/php-k8s
PHP K8s is a Kubernetes client for PHP and Laravel. Manage pods, deployments, services, and custom resources with a fluent API for CRUD, scaling, logs, exec, and watches—no kubectl required.
Install
composer require renoki-co/php-k8s
Requires PHP 7.4/8.x and Guzzle. There is no service provider or facade — this is a plain library, so in Laravel you bind it yourself (see below).
Where to look first
RenokiCo\PhpK8s\Kubernetes — the client/entry point. Every operation starts from an instance of this.src/K8s/ — one class per resource (K8sPod, K8sDeployment, K8sJob, K8sCustomResource, …). If you need a resource that isn't there, check before hand-rolling.src/Traits/ (HasSpecs, HasName, HasLabels, HasAttributes, HasMetrics, CanWatch, CanExec, CanScale, …) — this is where nearly all fluent methods actually live. When a method "doesn't exist", it's usually a trait you haven't checked.Authenticate
use RenokiCo\PhpK8s\Kubernetes;
// From a kubeconfig file on disk
$cluster = Kubernetes::fromKubeConfig('/home/me/.kube/config');
// From the KUBECONFIG env var (or ~/.kube/config fallback) — best for CI
$cluster = Kubernetes::fromKubeConfigVariable();
// In-cluster (service account token + CA at the standard mount path)
$cluster = Kubernetes::fromCluster();
// Explicit
$cluster = Kubernetes::getInstance('https://127.0.0.1:6443', $token, $caCert);
First use case: read something
$cluster = Kubernetes::fromKubeConfigVariable();
foreach ($cluster->getAllPodsByNamespace('default') as $pod) {
echo $pod->getName().' '.$pod->getPodPhase().PHP_EOL;
}
If that works, your auth, CA bundle and RBAC are all sane — which is 80% of the battle.
Fluent create/update/delete. Resources are mutable builders. Set attributes, then terminal call:
$deploy = $cluster->deployment()
->setName('api')
->setNamespace('prod')
->setLabels(['app' => 'api'])
->setReplicas(3)
->setImage('registry/api:1.24.0')
->setPorts([['name' => 'http', 'port' => 8080, 'targetPort' => 8080]])
->create();
$deploy->refresh()->setImage('registry/api:1.25.0')->update();
For anything the resource class doesn't expose yet, drop to raw attributes: ->setAttribute('spec.strategy.type', 'Recreate') or ->setSpec([...]).
Fetch one vs. list. Set a name (and namespace) then call get(); call the cluster-level getAll* helpers to list:
$pod = $cluster->pod()->setName('api-abc')->setNamespace('prod')->get();
$all = $cluster->getAllDeploymentsByNamespace('prod');
$byLabel = $cluster->getAllPodsByNamespace('prod')->whereLabel(['app' => 'api']);
Filtering with field selectors (whereName, whereNamespace, whereField) pushes work to the API server and is much cheaper than filtering a full list client-side — do it whenever you can.
Named clusters / multi-tenant. Kubernetes::getInstance($name) is keyed by name, so you can register several clusters once and pull them out anywhere:
Kubernetes::getInstance('eu', $euUrl, $euToken, $euCa);
Kubernetes::getInstance('us', $usUrl, $usToken, $usCa);
Kubernetes::getInstance('eu')->getAllNamespaces();
Bind that in a Laravel service provider and inject the cluster into jobs/commands rather than reaching for the singleton in domain code.
Deploy / rollback flow. ->setImage()->update() triggers a rolling update. For a "restart" you bump a pod-template annotation (->setAnnotations(['kubectl.kubernetes.io/restartedAt' => now()->toIso8601String()]) on the template spec via setAttribute) — there's no server-side restart verb. ->scale($n) handles replica changes; ->rollback() exists on deployments but only to the previous revision.
Exec and logs — the day-to-day win. This is why most people install the package:
$pod = $cluster->pod()->setName('api-abc')->setNamespace('prod');
$output = $pod->exec(['/bin/sh', '-c', 'php artisan migrate --force']);
$logs = $pod->logs(); // pass a container name for sidecars
$pod->logs($container, follow: true); // streams via callback/stream
Typical uses: running migrations from a CI step inside the cluster, tailing logs from a queue worker, and php artisan tinker-style debugging without kubectl.
Watching (light controllers). ->watchAll($callback) (and ->watch()) give you a raw HTTP watch stream. Fine for sidecars and reconciliation loops; not an informer — you own the resync and the resourceVersion bookkeeping.
CRDs and operators. The CRD story is the package's best feature. Register the CRD with K8sCustomResourceDefinition, then instantiate customResource() with group/version/kind/plural and treat it like any other resource:
$cluster->customResource()
->setName('my-thing')
->setGroup('example.com')->setVersion('v1')
->setKind('MyThing')->setPlural('mythings')
->setNamespace('default')
->setSpec(['size' => 3])
->create();
Because it extends the common resource base, CRUD, labels, watchers and metrics-based autoscaling all work on your CRs for free. If you're building a small operator in PHP (queue-driven reconciliation rather than a long-lived informer), this is the cleanest path.
Testing. Point KUBECONFIG at a k3d/kind cluster in CI and hit real objects; the package has no in-memory fake, and mocking the Guzzle layer is more work than spinning up a throwaway cluster.
Always refresh() before update(). Updates need the current resourceVersion. If you build a resource from scratch and call update(), you'll get a 409 conflict. Pattern: fetch → mutate → update, or retry on conflict.
Namespaces are not defaulted. setNamespace() is on you for namespaced resources. Omit it and the API server will 404 in ways that look like auth bugs.
CA certs and TLS. Self-signed clusters need the CA bundle passed as the third argument to getInstance()/fromKubeConfig. If you see Guzzle SSL errors, check the CA isn't being read relative to the wrong cwd.
Exec auth needs the binary in the container. If your kubeconfig uses exec (aws-iam-authenticator, gcloud, azure, 1Password plugins), the binary must exist and be on PATH wherever PHP runs. This is the #1 cause of Unauthorized in CI and in FPM containers. Cache the token rather than shelling out on every request.
In-cluster auth is path-sensitive. fromCluster() reads /var/run/secrets/kubernetes.io/serviceaccount/{token,ca.crt,namespace}. Works in a pod, fails silently-ish anywhere else. Guard with a file_exists check before choosing it as a fallback.
Watchers time out. The API server closes watch connections after a few minutes. Loop, pass the last resourceVersion, and back off on 410 Gone by re-listing. Don't treat a closed stream as an error.
Deletes are asynchronous. ->delete() returns once the API server accepts it, not once pods are gone. If order matters, poll getAllPods() with label selectors, or set propagation policy explicitly for cascading behavior.
Release cadence. Last release is April 2023, so newer Kubernetes API fields and newer resource kinds may lag. Prefer setAttribute()/setSpec() over waiting for a setter, and pin your kubernetes/php-client transitive dependency to avoid silent schema drift.
Errors are exceptions, not return codes. Catch RenokiCo\PhpK8s\Exceptions\KubernetesAPIException (it carries the API status) and KubernetesException for client-side problems. Log $e->getMessage() plus the response body — the API server's message is usually the actual answer.
Field vs. label selectors. whereLabel() and whereField() are not interchangeable; field selectors only support a small set of fields per kind (metadata.name, metadata.namespace, status.phase, spec.nodeName for pods). Everything else must be filtered client-side.
Debugging. Dump ->toArray() / ->getData() on a resource to see exactly what you'd be sending — it's the fastest way to spot a misplaced attribute before a confusing 422.
Extension points. Need a resource the package lacks? Extend K8sResource (or copy the nearest K8s* class), implement getPlural(), and it inherits fluent setters, CRUD and selectors. Register it on the cluster with the same magic accessor naming convention.
How can I help you explore Laravel packages today?