sebastian/environment
Detects runtime details of the PHP environment, including version, operating system, and whether running under HHVM, so your code can branch into runtime-specific execution paths.
1. Check whether you already have it. Any Laravel app with PHPUnit installed already ships this package transitively — phpunit/phpunit requires sebastian/environment, and it ends up on the coverage/console plumbing path. Confirm with:
composer show sebastian/environment
2. Declare it explicitly. If you're going to call it from your own code, add it yourself so the dependency isn't accidental:
composer require --dev sebastian/environment # tests, CI scripts, local tooling
composer require sebastian/environment # only if app/runtime code uses it in production
That --dev distinction matters more than usual here (see Gotchas).
3. Read two classes first. The whole package is essentially:
SebastianBergmann\Environment\Runtime — PHP version, SAPI-level facts, coverage drivers, OPcache, INI settings.SebastianBergmann\Environment\Console — TTY detection, terminal width/height, color support.Everything else (Host, added in 9.1.0) is incidental. Start with those two files in vendor/sebastian/environment/src/.
4. First real use case: stop guessing why --coverage fails in CI. This is the single most common day-to-day win in a Laravel repo:
php artisan test --coverage
# "No code coverage driver available" — or worse, silent 0% output
Instead of hardcoding "make sure Xdebug is on" in a wiki page, ask the package:
use SebastianBergmann\Environment\Runtime;
$runtime = new Runtime();
if (! $runtime->canCollectCodeCoverage()) {
// fail fast with a useful message instead of a broken report
}
canCollectCodeCoverage() accounts for Xdebug mode (since 7.2.1/8.0.1 — xdebug.mode=off correctly reports false), PCOV, and PHPDBG, which is exactly the set of conditions that trip people up when a CI image "has Xdebug" but nothing gets measured.
Don't sprinkle new Runtime() through controllers and providers. Register a singleton and put your app's policy on top of it:
// app/Providers/AppServiceProvider.php
use SebastianBergmann\Environment\Runtime;
public function register(): void
{
$this->app->singleton(Runtime::class, fn () => new Runtime());
}
// app/Support/Platform.php
namespace App\Support;
use SebastianBergmann\Environment\Runtime;
final readonly class Platform
{
public function __construct(private Runtime $runtime) {}
public function phpVersionId(): int
{
return $this->runtime->getVersionId();
}
public function atLeast(int $versionId): bool
{
return $this->runtime->getVersionId() >= $versionId;
}
public function canMeasureCoverage(): bool
{
return $this->runtime->canCollectCodeCoverage();
}
}
The wrapper is where your support policy lives (atLeast(80300) etc.), so the day you drop PHP 8.3 you edit one file — not forty version_compare() calls.
The idiomatic use inside a Laravel test suite is a precondition check that explains itself:
use SebastianBergmann\Environment\Runtime;
protected function requireCoverageDriver(): void
{
static $runtime = new Runtime();
if (! $runtime->canCollectCodeCoverage()) {
$this->markTestSkipped(
'No coverage driver: enable PCOV, or Xdebug with xdebug.mode=coverage.'
);
}
}
Pest equivalent:
it('renders the fiber-based scheduler', function () {
// ...
})->skip(
fn () => ! (new Runtime())->isXdebugActive(),
'Requires an active Xdebug session for tracing.'
);
Use the specific predicate (isXdebugActive() vs hasXdebug()) in the skip reason. hasXdebug() means "extension loaded", isXdebugActive() means "mode is not off" — the distinction explains 90% of "works on my machine" coverage tickets.
app:runtime Artisan command as your diagnostic surfaceA tiny command pays for itself the first time a teammate or CI runner misbehaves. It's also a good regression canary when you bump PHP versions:
final class RuntimeInfoCommand extends Command
{
protected $signature = 'app:runtime {--json : Output as JSON}';
public function handle(Runtime $runtime, Console $console): int
{
$data = [
'name' => $runtime->getName(), // "PHP"
'version' => $runtime->getVersion(),
'version_id' => $runtime->getVersionId(),
'binary' => PHP_BINARY,
'xdebug_loaded' => $runtime->hasXdebug(),
'xdebug_active' => $runtime->isXdebugActive(),
'pcov_loaded' => $runtime->hasPCOV(),
'pcov_active' => $runtime->isPCOVActive(),
'coverage' => $runtime->canCollectCodeCoverage(),
'opcache' => $runtime->isOpcacheActive(),
'interactive' => $console->isInteractive(),
'columns' => $console->getNumberOfColumns(),
];
$this->option('json')
? $this->line(json_encode($data, JSON_PRETTY_PRINT))
: $this->table(['Key', 'Value'], collect($data)->map(fn ($v, $k) => [$k, var_export($v, true)])->values()->all());
return self::SUCCESS;
}
}
The genuinely under-used feature. When you spawn a PHP child process (parallel test shards, a sandboxed artisan invocation, a worker using Process), you usually want it to inherit the current runtime configuration — but only the settings that are legal on the command line:
use Symfony\Component\Process\Process;
$runtime = app(Runtime::class);
$current = $runtime->getCurrentSettings(); // ['memory_limit' => '-1', ...]
$immutable = $runtime->getSettingsNotChangeableAtRuntime(); // e.g. php.ini-only directives
$flags = [];
foreach ($current as $key => $value) {
if (in_array($key, $immutable, true)) {
continue;
}
$flags[] = '-d';
$flags[] = "{$key}={$value}";
}
$process = new Process([PHP_BINARY, ...$flags, base_path('artisan'), 'migrate', '--force']);
$process->mustRun();
This is the same machinery PHPUnit uses internally, and it is why the 8.1.1/9.3.1 quoting fix and the 9.3.2 empty-string-override fix exist: values must survive the php -d key=value round-trip. If you build -d strings by hand, you will hit exactly those bugs.
Inside Artisan commands, prefer Symfony's $this->output->getFormatter() / isDecorated() — you already have it. Reach for Console when you're in a non-Symfony context: a plain bin/ script, a composer script, a pre-commit hook, a custom PHPUnit printer.
$console = new Console();
$width = max(40, $console->getNumberOfColumns() - 4);
$color = $console->hasColorSupport();
hasColorSupport() is aligned with Symfony's StreamOutput::hasColorSupport() since 7.2.0 and honours FORCE_COLOR (9.2.0) and NO_COLOR, so behaviour matches what devs see in artisan output. If you're rendering inside Artisan, don't re-implement it — you'll create two different colour policies.
Runtime validation belongs in the pipeline, not in a request path:
"scripts": {
"check-runtime": "@php -r \"require 'vendor/autoload.php'; \\$r = new SebastianBergmann\\Environment\\Runtime(); exit(\\$r->getVersionId() >= 80300 ? 0 : 1);\"",
"test": ["@check-runtime", "@php artisan test"]
}
Warn in local (log a Log::warning from a service provider), fail hard in ci. A middleware that inspects PHP version on every request is pure overhead for a value that can't change mid-process.
It is not app()->environment(). Laravel's App::environment() answers "am I in local / staging / production". This package answers "which PHP am I, what can it do, and what terminal am I attached to". They're orthogonal; naming a facade Environment (a common suggestion) creates a real collision of meaning. Call the wrapper Platform, PhpRuntime, or RuntimeInfo.
--no-dev will break production use. This is the most common Laravel-specific footgun. Because it usually arrives via PHPUnit, it lands in require-dev. If you start calling it from a controller or provider and deploy with composer install --no-dev, the class vanishes at runtime. Either declare it in require, or keep it strictly to tests/CI/Artisan-locally.
There is no isPhp81() / isPhpVersion('8.1') / isLinux() / hasExtension(). Those helpers appear in a lot of AI-generated advice but are not the API. What actually exists is getVersionId() (int, comparable directly to PHP_VERSION_ID constants like 80300), getName(), getVersion(), plus the coverage/OPcache predicates and getCurrentSettings()/getSettingsNotChangeableAtRuntime(). For extension checks just use extension_loaded(); wrapping it adds nothing. If you want isPhp81()-style ergonomics, define them yourself in the wrapper.
getCurrentSettings() can spawn a child process. Until PHP 8.6 it shells out to determine built-in default values (see 9.3.3). It is fine in a CLI diagnostic command; never call it per-request. Cache it (Cache::rememberForever) if you need it at runtime.
Keep it current if you round-trip settings. 8.1.1 / 9.3.1 fixed unquoted values breaking php -d, and 9.3.2 fixed empty-string overrides being forwarded for php.ini-only extensions. Old versions silently produce child processes with wrong configuration — a nasty, hard-to-trace class of bug.
getBinary() and getRawBinary() are deprecated. Use escapeshellarg(PHP_BINARY) or the PHP_BINARY constant directly. Same values, one less dependency surface.
isOpcacheActive() reflects the current SAPI. opcache.enable_cli is off by default, so a CLI command will report false on a machine where FPM happily has OPcache on. Don't use it to decide "should I purge the opcache" from an Artisan command without also checking that your CLI has it enabled.
canCollectCodeCoverage() is not "coverage will produce a report". It only proves a driver is present and enabled. The other two failure modes — no <source> filter configured, and pcov clashing with xdebug both loaded — are outside this package's scope.
Prefer Symfony Console inside Artisan. Console::hasColorSupport() and $this->output->isDecorated() can diverge in edge cases (piping, NO_COLOR, FORCE_COLOR) and having two colour policies in one command is a support burden. Historically there were also warnings in non-TTY environments (fixed in 8.0.4 / 9.0.1) — another reason to keep the dependency patched if you use it in scripts that run under cron or CI without a TTY.
Version pinning is dictated by your PHP floor. Each major drops the previous PHP minor (8.0.0 dropped 8.2; 9.0.0 dropped 8.3; 9.3.3 already handles PHP 8.6). On a Laravel app pinned to older PHP, Composer will resolve an older major for you — don't fight it, and don't copy snippets that use getSettingsNotChangeableAtRuntime() (9.3.0+) into a project stuck on 8.x.
HHVM is vestigial. The README still says "HHVM/PHP", but 7.2.0 removed code left over from when HHVM was supported. getName() will effectively always return PHP. Don't design a deprecation-warning system around isHHVM() in 2026 — there is no HHVM deployment to warn.
When not to use it. If all you need is a version check, PHP_VERSION_ID is a constant — zero dependency, zero indirection. This package earns its place for the things you'd otherwise get wrong by hand: coverage-driver detection with Xdebug mode awareness, INI-setting round-tripping into subprocesses, and TTY/width detection outside Symfony Console.
Extension points are deliberately absent. There's no plugin surface, no config file, no container hooks — the classes are final-ish, side-effect-free readers of process state. If you need testability, inject the small wrapper interface from Pattern 1 and fake that; don't try to mock Runtime itself, since it reads real globals and constants that PHPUnit-style mocks can't meaningfully control.
How can I help you explore Laravel packages today?