sensiolabs/consul-php-sdk
A thin PHP wrapper around the Consul HTTP API, covering agent, catalog, health, kv, session and txn services. Methods return a ConsulResponse and throw exceptions on 4xx/5xx errors, with handy lock and semaphore helpers for distributed coordination.
Install the maintained package, not the one in the title. The project was renamed when it moved to the FriendsOfPHP org: the Composer name is friendsofphp/consul-php-sdk and the namespace changed from SensioLabs\Consul to Consul. If you run composer require sensiolabs/consul-php-sdk you get the abandoned v4 line.
composer require friendsofphp/consul-php-sdk # v5.4+ requires PHP >= 8.2, symfony/http-client >= 6.4
docker run -d --name=dev-consul -p 8500:8500 -e CONSUL_BIND_INTERFACE=eth0 consul
Where to look first: Consul\Client (the HTTP plumbing and base URI/token handling), then src/Services/* — one class per Consul API area (KV, Session, Agent, Catalog, Health, Txn, plus v5.4's ACL, Config, Connect, Operator, Snapshot, Status, PreparedQuery, DiscoveryChain, Peering, Event, Coordinate). Skim the release/v5.4.0.md notes; they explain the option whitelisting and the CONSUL_HTTP_TOKEN behaviour you'll rely on.
First use case — register the app and read config from KV. Bind one configured client in a service provider so nothing else in the app constructs services ad hoc:
// config/services.php
'consul' => [
'base_uri' => env('CONSUL_HTTP_ADDR', 'http://127.0.0.1:8500'),
'token' => env('CONSUL_HTTP_TOKEN'),
],
// app/Providers/ConsulServiceProvider.php
public function register(): void
{
$this->app->singleton(Consul\ClientInterface::class, fn () => new Consul\Client(
config('services.consul.base_uri'),
config('services.consul.token'),
));
$this->app->bind(Consul\Services\KV::class, fn ($app) => new Consul\Services\KV($app->make(Consul\ClientInterface::class)));
$this->app->bind(Consul\Services\Agent::class, fn ($app) => new Consul\Services\Agent($app->make(Consul\ClientInterface::class)));
}
Then the three-line smoke test from the README, wrapped so failures are visible:
$kv = app(Consul\Services\KV::class);
$kv->put('myapp/config/feature_flags', json_encode(['new_checkout' => true]));
$flags = json_decode($kv->get('myapp/config/feature_flags', ['raw' => true])->getBody(), true);
Keep a docker compose service for Consul in your local stack and point CONSUL_HTTP_ADDR at it — otherwise every developer needs a native Consul install just to boot the app.
Wrap the SDK behind your own repository class. Every Consul\Services\* call returns Consul\ConsulResponse and throws on 4xx/5xx, so leaking it into controllers/jobs couples your domain to Consul's wire format. A thin ConsulConfigRepository with get()/put()/watch() methods makes it swappable for a config()-backed fake in tests — there is no built-in HTTP fake.
Service registration in a bootstrapper, heartbeat in the scheduler. Register on deploy/boot with Agent::registerService(), then pass a TTL check from a scheduled command:
// AppServiceProvider::boot() — or a dedicated `consul:register` artisan command run by your entrypoint
$agent->registerService([
'ID' => $serviceId = config('app.name').'-'.gethostname().'-'.getmypid(),
'Name' => config('app.name'),
'Address' => gethostbyname(gethostname()),
'Port' => (int) config('app.port'),
'Check' => ['TTL' => '30s', 'DeregisterCriticalServiceAfter' => '90s'],
]);
// app/Console/Kernel.php / routes/console.php
$schedule->call(fn () => app(Consul\Services\Agent::class)->passCheck($serviceId))
->everyFifteenSeconds();
Prefer TTL checks over script checks for PHP containers — there's no supervisor inside the container to answer an HTTP /health probe reliably during deploy churn.
Discovery: query Health, not Catalog. Health::service('billing', ['passing' => true, 'tag' => ['v1']]) gives you only instances whose checks pass. Cache the result (30–60s) in Laravel's cache — Consul lookups on every outbound request add a full HTTP round trip to every call path.
Blocking queries belong in a daemon, not a request. For push-style config propagation, run an artisan command under Supervisor that loops with the returned X-Consul-Index:
$index = 0;
while (true) {
$res = $kv->get('myapp/config', ['index' => $index, 'wait' => '5m', 'raw' => true]);
$index = $res->getHeaders()['X-Consul-Index'][0] ?? $index;
Cache::forever('consul.config', $res->getBody());
}
Set the Symfony HTTP client timeout well above wait (e.g. 330s), otherwise the transport blows up before Consul returns. This is also why blocking queries must never run inside an FPM/Octane request — they pin a worker for up to five minutes.
Distributed locks for cross-host mutual exclusion. Laravel's withoutOverlapping() uses your cache store; it will not stop two pods on different hosts. Use LockHandler / MultiLockHandler around scheduled jobs, always in a try/finally, and always check the boolean return rather than catching:
$lock = new Consul\Helper\MultiLockHandler($resources, 60, app(Session::class), app(KV::class), 'myapp/locks/');
if (! $lock->lock()) { return; }
try { $this->doWork(); } finally { $lock->release(); }
MultiSemaphore + Consul\Helper\Resource(name, limit, weight) is the right tool for capping concurrent queue workers across a fleet (Resource('importer', 3, 1) = max 3 concurrent).
Use Txn for atomic multi-key writes. Read-modify-write on separate put() calls is not atomic; a config rollout touching several keys can be observed half-applied. Consul\Services\Txn::create([...]) gives you check-and-set semantics in one request.
Consistency options are now available and worth setting deliberately. ['consistent' => true] on Catalog/Health reads after a write you just made, ['stale' => true] for high-throughput reads where a few seconds of lag is fine. And pass ['dc' => 'eu-west-1'] on any non-agent endpoint — v5.4 accepts it everywhere, which finally removes bespoke URL plumbing for cross-DC reads.
Pin the client to Consul\ClientInterface in constructor injection. Since 5.1 the SDK accepts the interface, so tests can pass an in-memory stub of the client rather than a real socket.
ClientException for 4xx (correct) and the README's ServeException for 5xx — the actual class is Consul\Exception\ServerException. Catch both, and catch Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface separately: connection refused / DNS failures are not wrapped in a Consul exception, so catch (ClientException|ServerException $e) alone will not make your code resilient to an agent outage.json() throws on non-JSON bodies. KV values are arbitrary strings. Use ->getBody() for raw payloads and only call ->json() on endpoints you know return JSON.KV::put(..., ['acquire' => $sessionId]) returns false in the JSON body when another node holds the lock. Check the value; don't wrap in try/catch.LockHandler handles renewal for you, but only while the process lives.config:cache will break your token if you read env() at runtime. Map CONSUL_HTTP_TOKEN into config/services.php and read it through config(). The SDK also reads the env var itself when no token is passed — pick one mechanism and be consistent, or you'll get "works on my machine" auth behaviour between local (env file) and production (injected secret).'tag' vs 'tags', 'index' casing) may simply be dropped rather than erroring. When a blocking query spins hot or a filter appears to do nothing, dump the built query string from the client — the silent-drop failure mode is the single most confusing thing about this SDK.['tag' => ['v1', 'primary']], not a comma-joined string.get() is a network round trip; reading config per-request in a middleware is a latency regression. Read once into the Laravel config repository at boot, then refresh via the blocking-query daemon.127.0.0.1 registered from inside a container is unreachable from other nodes. Resolve the container's routable IP or use CONSUL_SERVICE_ADDR explicitly, and always set DeregisterCriticalServiceAfter so dead pods don't linger in the catalog for days.Consul\ClientInterface boundary or a repository layer. The SDK's Client is deliberately thin and exposes no retry hook — don't fork it, decorate it.vendor/bin/simple-phpunit (the SDK's own suite) needs an agent at CONSUL_HTTP_ADDR; for your app's tests, stub the repository or ClientInterface bindings — spinning up Consul per test is slow and racy.How can I help you explore Laravel packages today?