launchdarkly/server-sdk
LaunchDarkly server-side SDK for PHP lets you evaluate feature flags, target users, and manage segments directly in your PHP applications, with streaming updates, polling, and local caching for fast, reliable flag delivery.
launchdarkly/server-sdk is LaunchDarkly's official PHP server-side SDK. The star count is low because most PHP teams only touch LD through the JS/browser SDK — the package itself is vendor-maintained and released on a regular cadence, so treat it as production-grade but documentation-light: expect to read src/ more than you would for a bigger ecosystem package.
Install and wire it up
composer require launchdarkly/server-sdk
Check the SDK's composer.json for the PHP floor (8.1+ on recent majors) and extensions — older majors need ext-curl + ext-json for the event publisher.
Server-side SDK keys are secrets. Keep them out of anything prefixed VITE_ / MIX_:
LAUNCHDARKLY_SDK_KEY=sdk-xxxxxxxx
// config/services.php
'launchdarkly' => [
'sdk_key' => env('LAUNCHDARKLY_SDK_KEY'),
'timeout' => env('LAUNCHDARKLY_TIMEOUT', 3.0),
],
Register it as a lazy singleton — do not construct the client in a provider's boot(), or every artisan invocation pays for a network handshake:
// app/Providers/AppServiceProvider.php
$this->app->singleton(\LaunchDarkly\LDClient::class, function () {
$key = config('services.launchdarkly.sdk_key');
// v5+: constructor takes a Config object.
$config = new \LaunchDarkly\Config($key);
$config->setLogger(Log::getLogger()); // any PSR-3 logger
$config->setTimeout(config('services.launchdarkly.timeout'));
return new \LaunchDarkly\LDClient($config);
// v4 and earlier: new \LaunchDarkly\LDClient($key, ['logger' => ..., 'timeout' => ...]);
});
First use case — a server-side kill switch. Build the evaluation target once and reuse it:
$client = app(\LaunchDarkly\LDClient::class);
$user = (new \LaunchDarkly\LDUserBuilder((string) $request->user()->id))
->email($request->user()->email)
->custom(['plan' => $request->user()->plan])
->build();
if ($client->variation('new-checkout', $user, false)) {
// new flow
}
Where to look first
src/LaunchDarkly/LDClient.php — variation(), variationDetail(), allFlagsState(), track(), flush(), close().src/LaunchDarkly/Config.php — every knob, including the logger, timeouts and data-source hooks.src/LaunchDarkly/Integrations/ — file-based data sources for tests/local dev.launchdarkly/hello-php — the smallest working example; useful for confirming your config before blaming Laravel.Wrap the client behind your own interface. This is the single highest-value pattern. LDClient is awkward to fake, so define App\Support\FeatureFlags with enabled(string $flag, ?Context $ctx = null, bool $default = false) and bind the LD-backed implementation in the container. In tests, bind a fake that reads from an array. Everything downstream becomes trivially testable and the LD API churn (see LDUser → Context) stays in one file.
Resolve the context once, in middleware. Build the context in a ResolveLaunchDarklyContext middleware from the authenticated user and bind it into the container. Controllers/jobs then ask for flags without rebuilding targeting attributes on every call — and, critically, the same context object is used for both server evaluation and any frontend bootstrap.
Bootstrap the frontend with allFlagsState() instead of evaluating twice:
$state = $client->allFlagsState($context, [
'clientSideOnly' => true,
'withReasons' => false,
]);
return Inertia::render('Dashboard', [
'flags' => $state->jsonSerialize(), // hand to the JS SDK as bootstrap data
]);
Pass this through Inertia shared props or a /api/flags endpoint, and initialise the browser SDK with it — no flicker, no duplicate evaluation, one round trip.
Blade ergonomics. Add a @feature('flag') ... @endfeature directive (Blade::if('feature', fn ($f) => app(FeatureFlags::class)->enabled($f))) so templates stop importing SDK classes.
Tracking is where the value is. $client->track('checkout-completed', $context, ['valueCents' => $order->total]) turns flags into measurable experiments. Pair it with app()->terminating(fn () => $client->flush()) in AppServiceProvider so events actually leave the process before PHP-FPM tears down the request.
Defaults encode the future. Always pass the post-cleanup value as the default (variation('new-checkout', $ctx, false) where false is the old behaviour only if you intend to roll back). When the flag is deleted, the code keeps working with no diff — that is the whole point of the exercise.
Queued jobs and Octane.
flush() per job, and close() only via Queue::stopping().flush() on RequestTerminated and never call close() mid-run.Local dev and CI. Point the SDK at a file data source (LaunchDarkly\Integrations\Files) with YAML fixtures, or run the launchdarkly/ld-relay Docker image and set the SDK host to it. Either way, tests must not depend on a live LD connection — evaluation is done locally from the downloaded payload, so a fixture-driven client behaves identically to production.
php artisan migrate shouldn't create an LD client. Either rely on lazy resolution (preferred) or short-circuit when app()->runningInConsole().flush() vs close(). flush() sends queued events and is safe to call repeatedly; close() shuts the client down permanently. In a worker, flushing per request and closing at shutdown is the correct pattern — closing after the first request leaves the rest of the worker's lifetime flagless.variationDetail(), not logging. It returns the value, the variation index, isDefaultValue(), and a reason (RULE_MATCH, FALLTHROUGH, ERROR, PREREQUISITE_FAILED). When a flag "isn't working", the reason tells you whether it's targeting, a prerequisite, or a malformed context — usually in one shot.LDUser/LDUserBuilder to Context. The migration is mechanical but touches every call site, and the old builder is deprecated rather than removed. Pin the major version in composer.json and read the changelog before bumping.php artisan config:cache freezes env reads. The SDK key is read once into config, which is fine — but a key rotated in .env won't take effect until you re-cache. Also, an empty key usually means the client silently falls back to defaults rather than throwing, so add a boot-time assertion in production.variationDetail() reports FLAG_NOT_FOUND.config/services.php per environment and never share one across staging/production.NOASSERTION, the repo is Apache-2.0. If you have a licence-audit gate, verify against the repo's LICENSE file rather than the package metadata.FeatureFlags interface. Expect to read the SDK source — with 39 stars there are very few blog posts to lean on, and most of them are out of date.How can I help you explore Laravel packages today?