Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Os Process Laravel Package

juststeveking/os-process

A lightweight Laravel package for detecting the operating system your app runs on. Check for macOS, Windows, Linux and more through a simple, expressive API, then branch your code accordingly—useful for CLI tools, environment checks, and platform-specific logic.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and confirm the plumbing first.

composer require juststeveking/os-process

The package is Laravel auto-discovered and framework-agnostic (it delegates to symfony/process, which you almost certainly already have transitively). If the service provider publishes config, publish it once and commit it — Laravel's config cache will otherwise hide any runtime edits:

php artisan vendor:publish --tag=os-process-config

Where to look first. There is no meaningful documentation beyond the README: 1.0.0 (2022-08-30) shipped with a diff-only changelog, 76 stars, and zero dependents. Read the source, in this order, before writing a line of application code:

  1. composer.json — PHP/Laravel constraints and the exact symfony/process range.
  2. The process/builder class — how it constructs the command: does it pass an argument array to Symfony\Component\Process\Process, or interpolate a shell string? This single answer determines your injection risk.
  3. The result object — confirm the accessors for exit code, stdout, stderr, and success (successful(), output(), errorOutput() per the docs).
  4. The facade/accessor wiring — where the container binding lives and whether you can swap it.

The documented fluent chain is command()->path()->timeout()->run(). Verify exact namespaces and method names against src/; treat the source as the specification.

First use case: a read-only probe. Do not start with a destructive command. Use something idempotent and always-present, so a failure is a code failure, not an environment failure:

use JustSteveKing\OsProcess\Facades\OsProcess; // confirm namespace against src/

$result = OsProcess::command(PHP_BINARY)
    ->arguments(['-r', 'echo PHP_VERSION;'])
    ->path(base_path())
    ->timeout(5)
    ->run();

if (! $result->successful()) {
    throw new RuntimeException('Probe failed: '.$result->errorOutput());
}

$version = trim($result->output());

Using PHP_BINARY for the first smoke test removes the "binary not installed" variable entirely. Validate against a real target binary (git, ffmpeg, mysqldump) only after the wiring is proven.

Before you commit, one environment gate:

if (! function_exists('proc_open') || in_array('proc_open', explode(',', ini_get('disable_functions')))) {
    // fail loudly at boot / health check, not at the call site at 2am
}

No wrapper fixes a disabled proc_open. Managed hosting and locked-down containers will break here, and you want that discovered in CI, not production.

Implementation Patterns

Wrap it behind a project-owned contract. This is the highest-leverage decision you will make with this package. Never let OsProcess leak into jobs, domain services, or controllers.

namespace App\Process;

interface ProcessRunner
{
    public function run(ProcessCommand $command): ProcessResult;
}

Bind the implementation in a service provider, and keep it small — thirty lines, no logic. If the package goes stale or you move to Laravel's own Process facade, the swap touches one file.

Model the command as a value object, and validate it against an allow-list.

final class ProcessCommand
{
    private function __construct(
        public readonly string $binary,
        public readonly array $arguments,
        public readonly ?string $cwd = null,
        public readonly int $timeout = 30,
    ) {}

    public static function make(string $binary): self
    {
        // Reject anything that isn't a bare basename or absolute path.
        if (! in_array($binary, config('process.permitted'), true)) {
            throw new InvalidArgumentException("Binary not permitted: {$binary}");
        }

        return new self($binary, []);
    }
}

Arguments are always an array. The value object is also the natural place to enforce a mandatory timeout and a mandatory working directory, so no call site can forget them.

Handle the result in one place. Distinguish three outcomes, not two — success, non-zero exit, and timeout (Symfony\Component\Process\Exception\ProcessTimedOutException). A timeout is not "failure with no output"; it usually means a hung child process.

public function run(ProcessCommand $command): ProcessResult
{
    $start = microtime(true);

    try {
        $result = OsProcess::command($command->binary)
            ->arguments($command->arguments)
            ->path($command->cwd ?? base_path())
            ->timeout($command->timeout)
            ->run();
    } catch (ProcessTimedOutException $e) {
        Log::warning('process.timeout', ['binary' => $command->binary]);
        throw new ProcessTimedOut($e->getMessage(), previous: $e);
    } finally {
        // record duration, binary, exit code — never the raw args if they carry secrets
    }

    return new ProcessResult(
        exitCode: $result->exitCode(),
        stdout: $result->output(),
        stderr: $result->errorOutput(),
    );
}

Log the binary name, exit code, and duration; log the full argument vector only when arguments are known to be secret-free.

Artisan commands are the best fit. An operator-invoked command shelling out to rsync, docker, or a deploy script has a deliberate call site, a human watching, and an obvious place for $this->info()/$this->error().

Queues for anything slow. Anything over a second or two goes in a job, and the job $timeout must be set above the process timeout so the worker does not kill the job mid-flight and orphan the child:

class TranscodeVideo implements ShouldQueue
{
    public $timeout = 1200; // process timeout is 900
}

Parse output at the boundary. ffprobe returns JSON; git returns lines. Do the decoding in the adapter or a dedicated parser, not in the caller, and treat unparseable output as a distinct failure from a non-zero exit code.

Test the contract, not the binary. Inject a fake ProcessRunner for application tests. For the adapter itself, use PHP_BINARY so the test suite never depends on ffmpeg being installed in CI:

$runner = new OsProcessRunner();

$this->assertTrue($runner->run(ProcessCommand::make(PHP_BINARY, ['-r', 'exit(0);']))->successful());
$this->assertSame(3, $runner->run(ProcessCommand::make(PHP_BINARY, ['-r', 'exit(3);']))->exitCode);
$this->assertStringContainsString('boom', $runner->run(ProcessCommand::make(PHP_BINARY, ['-r', 'fwrite(STDERR, "boom"); exit(1);']))->stderr);

Cover success, non-zero exit, timeout (sleep), and missing binary. These four are where production incidents come from.

Gotchas and Tips

The package is stable, not maintained. One release in 2022, a solo maintainer, no CVE process, no support policy. Pin the exact version in composer.json, do not float, and subscribe to the repo's releases. Budget an afternoon for the source audit up front. Assume you are the maintainer of last resort.

Argument injection is the whole ballgame. If the builder ever accepts a pre-joined string, or if you interpolate user input into an argument, you have a shell injection vector regardless of what Symfony does downstream. Pass arrays. Reject user input that reaches a binary name at all — binaries come from an allow-list in config, never from a request.

PATH differs across contexts. The web user, the queue worker, and your SSH session resolve git to different paths, or don't resolve it at all. Use absolute paths in config (/usr/bin/git), or resolve once at deploy time. This is the single most common "works locally, 127 in production" cause.

Exit codes carry meaning — check 127 and 126 first. 127 is "command not found" (a PATH or install problem), 126 is "found but not executable" (a permissions problem), 143 is SIGTERM. Surface these distinctly in logs; they are environment failures, not application failures, and conflating them with 1 wastes hours.

Output is buffered in memory. output() loads the entire stdout into a string. A mysqldump or a noisy build will exhaust memory_limit. For anything large, redirect to a file inside the command (> /tmp/x.sql) or stream via Symfony's callback API rather than using the wrapper's convenience accessors.

Timeouts cut both ways — orphaned children. When Symfony kills a timed-out process it may leave grandchildren (sh -c wrappers, npm spawning node) running. Set an explicit timeout on every command, and if you shell out to something that forks, consider terminating the process group yourself.

Windows is a separate product. If any developer or runner is on Windows, path quoting, .bat vs .exe, and the absence of which will bite. Test escaping and termination there before you commit; thin wrappers break most often at this seam.

Config caching in Laravel. php artisan config:cache freezes your published os-process config. Any per-environment binary paths or allow-lists must be set before the cache is built — otherwise staging points at production binaries or vice versa. Never read config at class-construction time in a long-running worker.

Extension points are decorative. There is no plugin system and no event surface worth relying on. Extend by decoration, not by forking or subclassing: a LoggingProcessRunner and a MetricsProcessRunner wrapping your own ProcessRunner contract gives you observability without coupling to package internals.

Never run unbounded batches from a request. Process execution scales on OS process slots, file descriptors, and memory — a different axis from PHP-FPM workers. Cap concurrency explicitly, monitor OS process counts separately, and treat a loop of run() calls as a design smell.

Debugging a failure: reproduce the exact command, in the exact context. Log the full command line once (in a debug build), then run it as the same user (sudo -u www-data) with the same cwd and environment. Nine times out of ten the wrapper is fine and the environment is not.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums
rlacerda83/query-parser
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings