php-standard-library/os
Detect the operating system in a type-safe way using PHP Standard Library's OperatingSystemFamily enum. Part opf the php-standard-library project, this small package gives you a clean, enum-based API for OS detection instead of fragile string checks.
1. Install (and check the version constraint first)
composer require php-standard-library/os
Don't blindly pin ^1.0. This package ships from the php-standard-library monorepo and its release tags track the parent project (the release notes here are 6.1.0–6.2.1, each just pointing at the main repo). Match whatever major the monorepo is currently on, or let Composer resolve it.
2. Look at the class before you look at the docs
The README is a stub — a title, a one-line description, and links out to php-standard-library.dev. The release notes are equally thin. The actual API is one small enum class:
vendor/php-standard-library/os/src/OperatingSystemFamily.php
Open it. It's on the order of twenty lines. Two things to confirm there before you write any code:
OperatingSystemFamily::current(), another OperatingSystemFamily::fromCurrent(). Only one exists.Windows, Darwin, Linux, BSD, Solaris, Unknown? uppercase? string or int backed?). IDE autocompletion after OperatingSystemFamily:: answers this in a second.3. First use case: guard an Artisan command
The highest-value, lowest-risk first integration is a command that shells out to something platform-specific:
use PhpStandardLibrary\Os\OperatingSystemFamily;
public function handle(): int
{
$os = OperatingSystemFamily::current(); // adjust to the real method name
if (! $os->is(OperatingSystemFamily::LINUX) && ! $os->is(OperatingSystemFamily::DARWIN)) {
$this->components->error("This command requires a POSIX system; detected {$os->name}.");
return self::FAILURE;
}
// ...
}
4. Sanity-check in the REPL
php artisan tinker --execute="dump(PhpStandardLibrary\Os\OperatingSystemFamily::current());"
Run it once in your container and once on a Windows checkout. If both print the same case, you have a container/host confusion problem (see Gotchas).
Inject the enum; don't call the static constructor inside services.
This is the pattern that matters most. current() reads the host at call time and is not fakeable. Injecting the enum makes every OS-dependent service unit-testable:
// AppServiceProvider::register()
$this->app->singleton(OperatingSystemFamily::class, fn () => OperatingSystemFamily::current());
final class BinaryResolver
{
public function __construct(private OperatingSystemFamily $os) {}
public function nullDevice(): string
{
return $this->os->is(OperatingSystemFamily::WINDOWS) ? 'NUL' : '/dev/null';
}
public function findCommand(string $name): string
{
return $this->os->is(OperatingSystemFamily::WINDOWS) ? "where {$name}" : "which {$name}";
}
}
Then in tests: new BinaryResolver(OperatingSystemFamily::WINDOWS) — no mocking framework, no runkit, no @requires gymnastics. You can exercise the Windows branch from Linux CI.
Use it as a container discriminator for strategy implementations.
Instead of if/else chains scattered through a service, bind OS-specific implementations once:
$this->app->bind(PermissionSetter::class, fn ($app) =>
$app->make(OperatingSystemFamily::class)->is(OperatingSystemFamily::WINDOWS)
? new WindowsAclPermissionSetter()
: new PosixChmodPermissionSetter()
);
Now the rest of the app is OS-agnostic and your conditional logic lives in exactly one file.
Cache it where it's hot — or, better, don't make it hot.
The enum construction is trivially cheap, but anything in per-request middleware or a loop over N files shouldn't re-derive it. The singleton binding above covers this for free. If you're on a static-call style, hoist it to a local variable once per method.
Log it as request context.
Adding the OS family to your exception reporter / log processor (Sentry, flare, a Monolog processor) turns "works on my machine" into "this failed on Windows" in the first support thread. Cheap, high-leverage.
When migrating, do it opportunistically.
There's no automated path from str_contains(PHP_OS, 'WIN') to the enum. Do it while you're already touching a file, and put a PHPStan custom rule (or a grep in CI) on new code so the old pattern stops spreading. Rewriting a hundred legacy checks in one PR buys you nothing but review fatigue.
The enum answers "what kernel am I on", not "what can I do." Don't use it as a proxy for capability. OperatingSystemFamily::LINUX does not imply exec() is enabled, posix_* is available, chmod behaves, or systemd is present. Check the capability directly (function_exists('posix_geteuid'), ini_get('disable_functions')). Conversely OperatingSystemFamily::WINDOWS doesn't mean "no symlinks" — modern Windows has them.
PHP_OS_FAMILY is coarse, and the enum inherits that coarseness. PHP itself only distinguishes Windows, Darwin, Linux, BSD, Solaris, Unknown. FreeBSD, OpenBSD and NetBSD all land in BSD; every Linux distro is Linux; macOS versions are indistinguishable. If you need distro or version granularity, wrap this enum in your own value object — you cannot extend a PHP enum.
WSL reports Linux. Docker Desktop on macOS reports Linux. A Linux container on a Windows host reports Linux. If your feature needs to know about the human's machine (opening a browser, a file picker, a tray icon) rather than the process's kernel, this package will confidently give you the wrong answer. That's not a bug — it's the correct answer to a different question.
The enum is not extensible, so plan the fork. Adding FreeBSD or WSL or Android means a PR upstream or a vendored fork. Twenty lines is forkable; just know that's the escape hatch, and prefer an app-level wrapper so consumers don't depend on the vendored class directly.
An unknown OS may throw or may return Unknown — check which before shipping to an exotic platform. If the constructor throws on unrecognised values (from() semantics rather than tryFrom()), a niche CI runner becomes a hard boot failure. Wrap the container binding in a try/catch that falls back to Unknown unless you genuinely want to fail closed.
PHPUnit already has this. For test skipping specifically, #[RequiresOperatingSystemFamily('Linux')] (PHPUnit 10+) reads PHP_OS_FAMILY directly — you don't need the dependency for that. The package earns its place in production code where you want a typed value flowing through the container and the type checker, not in test annotations.
Your CI matrix is the real test. A Linux-only pipeline means every is(OperatingSystemFamily::WINDOWS) branch is dead code that nobody has ever executed. At minimum, add a windows-latest and macos-latest leg that runs the subset of tests touching OS-conditional services. Without that, the type safety is real but the coverage isn't.
Zero stars is a real signal, but a small one here. The risk isn't code quality (it's MIT, twenty lines, actively released) — it's that nobody else will file the bug for you. Budget an hour to read the whole class and write your own smoke test asserting current() returns non-null and doesn't throw on each platform you support. That test is your actual dependency contract.
How can I help you explore Laravel packages today?