Kubernetes::fake() swaps the manager — behind the facade and in the container, so injected KubernetesManagers get it too — for an in-memory apiserver, and returns the KubernetesFake handle. Nothing reaches the network. Every cluster the manager hands out (named, default, ad-hoc, namespace-scoped) and every resource bound to one talks to the fake, which records each request:
use RoundlyConsulting\KubernetesApi\Facades\Kubernetes;
use RoundlyConsulting\KubernetesApi\Resources\Deployment;
it('scales checkout for the sale', function () {
$fake = Kubernetes::fake()->seed(Deployment::class, [
['metadata' => ['name' => 'checkout', 'namespace' => 'shop'], 'spec' => ['replicas' => 2]],
]);
app(PrepareForSale::class)(); // your code: Kubernetes::namespace('shop')->deployments()->setName('checkout')->scale(10)
$fake->assertScaled(Deployment::class, 'checkout', 10);
$fake->assertNothingDeleted();
});The fake behaves like a small apiserver: seeded and created objects can be listed (label and field selectors, limit and continue, all namespaces), found, updated, patched, scaled and deleted. A missing object is a real 404, a duplicate create or a stale resourceVersion a real 409, and dryRun() requests validate without persisting. As on the apiserver, a create with metadata.generateName and no name gets <generateName> plus five random characters, update(), updateOrCreate() and patch() keep the stored status (a status in the body is ignored), and a limit of 0 or less means no limit. Each cluster name has its own store; ad-hoc clusters share the default cluster’s.
Set-up
| Set-up | Effect |
|---|---|
seed(string $resource, array $items, ?string $cluster = null) | Put objects — manifests or resource objects — on a cluster. $resource is a class or a registered name ('pods'). A namespaced item without metadata.namespace lands in the target cluster’s default namespace, where create() through that cluster would put it; an unknown cluster name throws ClusterNotFoundException. |
seedLogs(string $pod, string $logs, string $namespace = 'default', ?string $cluster = null) | What logs() and streamLogs() return; tailLines is honoured. |
stubExec(ExecResult|Closure $result) | What exec() returns — a fixed result, or a closure given the RecordedRequest. Default: empty output, exit code 0. |
stubVersion(VersionInfo|string $version) | What version() reports; default v1.34.0. |
unreachable(bool $unreachable = true) | Every request throws ConnectionException; ping() returns false. |
recorded(?Closure $filter = null) | Every RecordedRequest the fake received, optionally filtered. |
Logs, exec and watches work against the fake too — a watch streams one ADDED event per matching object:
use RoundlyConsulting\KubernetesApi\DataTransferObjects\ExecResult;
use RoundlyConsulting\KubernetesApi\DataTransferObjects\PodLogOptions;
use RoundlyConsulting\KubernetesApi\DataTransferObjects\WatchEvent;
use RoundlyConsulting\KubernetesApi\Facades\Kubernetes;
it('serves seeded pods, logs and exec', function (): void {
$fake = Kubernetes::fake()
->seed('pods', [
['metadata' => ['name' => 'api', 'namespace' => 'shop', 'labels' => ['app' => 'web']]],
])
->seedLogs('api', "one\ntwo\nthree\n", namespace: 'shop')
->stubExec(new ExecResult("hi\n", '', 0));
$pod = Kubernetes::namespace('shop')->pods()->setName('api');
expect($pod->logs(new PodLogOptions(tailLines: 2)))->toBe("two\nthree")
->and(iterator_to_array($pod->streamLogs(), false))->toBe(['one', 'two', 'three'])
->and($pod->exec(['sh', '-c', 'echo hi'])->stdout)->toBe("hi\n");
// A watch streams one ADDED event per matching object
$events = [];
Kubernetes::namespace('shop')->pods()->whereLabel('app', 'web')
->watch(function (WatchEvent $event) use (&$events): void {
$events[] = $event;
});
expect($events)->toHaveCount(1)
->and($events[0]->isAdded())->toBeTrue();
$fake->assertExecuted('api', ['sh', '-c', 'echo hi']);
});Assertions
| Assertion · nothing-variant | Example | Passes when |
|---|---|---|
assertSent(Closure) · assertNothingSent() | $fake->assertSent(fn (RecordedRequest $r): bool => $r->cluster === 'production') | Any request matches — or no request was sent at all. |
assertCreated($resource, $constraint = null) · assertNothingCreated() | $fake->assertCreated('configMaps', 'settings') | A create of that resource — optionally that name, or matching the closure. A service-account token request counts as a create. |
assertUpdated($resource, $constraint = null) · assertNothingUpdated() | $fake->assertUpdated(Deployment::class, 'checkout') | A full update — update(), or updateOrCreate() on an existing object. |
assertPatched($resource, $constraint = null) · assertNothingPatched() | $fake->assertPatched('deployments', 'checkout') | Any PATCH — patch(), scale() and rolloutRestart() included. |
assertScaled($resource, $name, ?int $replicas = null) · assertNothingScaled() | $fake->assertScaled(Deployment::class, 'checkout', 10) | A scale() — to that many replicas when given. |
assertRestarted($resource, $name) · assertNothingRestarted() | $fake->assertRestarted(Deployment::class, 'checkout') | A rolloutRestart(). |
assertDeleted($resource, $constraint = null) · assertNothingDeleted() | $fake->assertDeleted('pods', 'api') | A delete. |
assertExecuted(string $pod, ?array $command = null) · assertNothingExecuted() | $fake->assertExecuted('api', ['sh', '-c', 'echo hi']) | An exec() in that pod — with exactly that command when given. |
assertTokenRequested(string $serviceAccount, ?string $namespace = null, ?int $expirationSeconds = null) · assertNoTokenRequested() | $fake->assertTokenRequested('deployer', 'ci', 900) | A requestToken() for that service account — in that namespace and for that lifetime when given. |
$resource is a resource class or a registered name ('pods'); the constraint is an object name or a closure that receives the RecordedRequest. Dry-run requests are recorded but never satisfy the mutation assertions:
use RoundlyConsulting\KubernetesApi\Facades\Kubernetes;
use RoundlyConsulting\KubernetesApi\Resources\Deployment;
use RoundlyConsulting\KubernetesApi\Testing\RecordedRequest;
$fake->assertCreated('configMaps', fn (RecordedRequest $request): bool => $request->namespace === 'shop'
&& $request->input('data.FEATURE_X') === 'on');
// Everything the fake received, optionally filtered
$dryRuns = $fake->recorded(fn (RecordedRequest $request): bool => $request->isDryRun());
// The assertions are also callable on the facade once it is faked
Kubernetes::assertScaled(Deployment::class, 'checkout', 10);A RecordedRequest carries cluster (null for ad-hoc clusters), method, path, verb, apiVersion, plural, namespace, name, subresource, query, the decoded body, contentType, and command and container for exec — plus isDryRun(), input('spec.replicas') and isRolloutRestart().
Service-account tokens
A token request is recorded as a create on the token subresource, with name set to the service account — so assertCreated('serviceAccounts', 'deployer') sees it and assertNothingCreated() fails on it. The fake answers with a 404 unless the service account was seeded in that cluster and namespace; otherwise with a deterministic token, fake-token-{namespace}.{name}.{n} (n counts the fake’s token requests from 1), an expiry of now plus expirationSeconds (default 3600 — freeze time with Carbon::setTestNow()), the requested audiences (default https://kubernetes.default.svc.cluster.local) and any binding echoed back:
use Illuminate\Support\Carbon;
use RoundlyConsulting\KubernetesApi\Facades\Kubernetes;
Carbon::setTestNow('2026-10-10T12:00:00Z');
$fake = Kubernetes::fake()->seed('serviceAccounts', [
['metadata' => ['name' => 'deployer', 'namespace' => 'ci']],
]);
$token = Kubernetes::namespace('ci')->serviceAccounts()->setName('deployer')->requestToken(900);
$token->token; // 'fake-token-ci.deployer.1'
$token->expiresAt->toIso8601ZuluString(); // '2026-10-10T12:15:00Z'
$fake->assertTokenRequested('deployer', 'ci', 900);
Kubernetes::assertTokenRequested('deployer'); // through the facade
$fake->assertCreated('serviceAccounts', 'deployer'); // a token request is a create tooWhat the fake does not do
- Clusters registered before fake() carry over, and their definitions still run; under the fake, fromKubeConfig(), inCluster() and the kubeconfig and in-cluster config sources never read credentials.
- Like the apiserver, the fake answers a server-side apply without fieldManager, or force on any other patch type, with a 422.
- Strategic-merge and server-side-apply patches are applied as JSON merge patches, and logs and exec answer for any pod.
- Deletes are immediate. A real delete() is a Foreground deletion: the apiserver keeps the object, marked with a deletionTimestamp, until its dependents are gone (a pod also waits out its grace period), so a GET still answers and a re-create gets a 409. The fake removes the object at once — test delete-then-recreate flows against a real cluster.
- status is kept on every kind, including custom resources without a status subresource, where a real apiserver would store what you send.
- Clusters built without the manager — new Cluster, Cluster::make() — bypass the fake.
Wire-level control with Http::fake()
Http::fake() still works — it intercepts the real HTTP transport, for tests that want to assert exact URLs and headers. Disable client-side rate limiting there so a large suite never waits on the in-memory budget:
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use RoundlyConsulting\KubernetesApi\Cluster;
use RoundlyConsulting\KubernetesApi\Facades\Kubernetes;
beforeEach(function (): void {
config(['kubernetes.rate_limits.enabled' => false]);
Kubernetes::registerCluster('testing', fn (Cluster $cluster): Cluster => $cluster
->url('https://k8s.example.com')
->withToken('fake-token')
->withManagerName('shop-tests'));
Http::preventStrayRequests();
});
it('lists the deployments in a namespace', function (): void {
Http::fake([
'*' => Http::response([
'items' => [
['metadata' => ['name' => 'checkout', 'namespace' => 'shop']],
],
]),
]);
$deployments = Kubernetes::cluster('testing')->deployments()->setNamespace('shop')->get();
expect($deployments)->toHaveCount(1)
->and($deployments->first()->getName())->toBe('checkout');
Http::assertSent(fn (Request $request): bool => $request->method() === 'GET'
&& $request->url() === 'https://k8s.example.com/apis/apps/v1/namespaces/shop/deployments?pretty=1'
&& $request->hasHeader('Authorization', 'Bearer fake-token'));
});exec() opens its own WebSocket instead of using the HTTP client, so Http::fake() doesn’t intercept it — stub it with Kubernetes::fake(), or assert the exact subresource path and query getExecPath() builds for a command:
$path = Kubernetes::cluster('testing')->pods()
->setNamespace('shop')
->withName('checkout-5f7c9d8b6-x2kqj')
->getExecPath(['php', 'artisan', 'cache:clear'], container: 'app');
expect($path)
->toContain('/api/v1/namespaces/shop/pods/checkout-5f7c9d8b6-x2kqj/exec?')
->toContain('container=app')
->toContain('command=cache%3Aclear');The package’s own suites
The package’s default suite is fully faked and needs no cluster. A separate, opt-in suite runs the real CRUD, exec and listing matrix against a local OrbStack cluster; it is guarded so it only runs when K8S_INTEGRATION=1, the context is exactly orbstack and the apiserver host is loopback or *.orb.local, and each run uses a throwaway namespace that is deleted afterwards:
composer test
# Opt-in live suite against a local OrbStack cluster
K8S_INTEGRATION=1 composer test-integration| Variable | Default | Purpose |
|---|---|---|
K8S_INTEGRATION | unset | Set to 1 (or true) to enable the suite; without it every integration test skips. |
K8S_INTEGRATION_CONTEXT | orbstack | The kube context the suite takes its credentials from. It must be orbstack. |
K8S_INTEGRATION_NAMESPACE | random k8s-it-… | Overrides the throwaway namespace the suite creates and cleans up. |
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.