mohannadelemary / laravel-console-streams
Opt-in Artisan trait that keeps machine data on STDOUT and sends console components, line(), info() and other diagnostics to STDERR, so `artisan foo --json | jq` just works.
Package info
github.com/MohannadElemary/laravel-console-streams
pkg:composer/mohannadelemary/laravel-console-streams
Requires
- php: ^8.3
- illuminate/console: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- symfony/console: ^7.2|^8.0
Requires (Dev)
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
- symfony/process: ^7.2|^8.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
An opt-in trait for Artisan commands that keeps machine data on STDOUT and sends
everything else on STDERR — components->task(), components->info(), line(), table(), prompts and friends —
so this just works:
php artisan users:export --json | jq '.[0]' php artisan users:export --json > users.json # progress still shows in your terminal
It exists because of laravel/framework#56602: console
components always write to STDOUT, so a task() line ends up inside your JSON. Three attempts to change that in the
framework (#57293, #58727,
#60118) were closed — mainly because moving all component output
to STDERR globally is a breaking change, and because components are also used for successful results, not only progress.
This package is the package-shaped answer: nothing global, only the commands you opt in.
Installation
composer require mohannadelemary/laravel-console-streams
Requires PHP 8.3+ and Laravel 12 or 13. No service provider, no config.
Usage
use Illuminate\Console\Command; use MohannadElemary\ConsoleStreams\Concerns\WritesDiagnosticsToStderr; class ExportUsers extends Command { use WritesDiagnosticsToStderr; protected $signature = 'users:export {--json}'; public function handle(): int { $this->components->info('Exporting users'); // STDERR $this->components->task('Fetching rows', fn () => $this->fetch()); // STDERR $this->table(['id', 'name'], $rows); // STDERR $this->warn('2 rows skipped'); // STDERR $this->writeJson($rows); // STDOUT return self::SUCCESS; } }
Demo (real output from the test workbench)
$ php artisan demo:export 2>/dev/null | jq -c '.[].name' "Alice <info>tag</info>" "Böb/ü" $ php artisan demo:export 2>&1 >/dev/null # what lands on STDERR INFO Exporting users. Fetching rows .................................................. 0.03ms DONE WARN 2 rows skipped. ...
API
| Method | Purpose |
|---|---|
writeJson($data, $flags = PRETTY_PRINT|UNESCAPED_SLASHES|UNESCAPED_UNICODE) |
Encode and write to STDOUT (throws on invalid data). |
writeStdout(string $data, bool $newline = true) |
Raw write to STDOUT: no <tag> parsing, no ANSI, printed even with --quiet. |
stdout() |
The original STDOUT-backed OutputStyle, for anything else you need on STDOUT. |
shouldWriteDiagnosticsToStderr(): bool |
Override to activate conditionally, e.g. return (bool) $this->option('json');. Default true. |
diagnosticsFallback(): string |
'passthrough' (default), 'discard' or 'throw' — see below. |
Redirect only in JSON mode, so your human-facing output is unchanged otherwise:
protected function shouldWriteDiagnosticsToStderr(): bool { return (bool) $this->option('json'); }
How it works (and why it is not fragile)
Symfony runs Command::initialize() after Laravel has built $this->output / $this->components and after the input is bound.
The trait implements that public lifecycle hook and, for that one command only, replaces the two properties with an
Illuminate\Console\OutputStyle and View\Components\Factory backed by ConsoleOutput::getErrorOutput().
No framework class is extended, overridden, macro'd or monkey-patched, and no other command is affected.
A control command without the trait is part of the test suite and must keep writing everything to STDOUT.
Because the error output is Symfony's own, behaviour is inherited rather than re-implemented:
- ANSI — decided per stream (STDERR can be colourised while STDOUT is piped);
--ansi/--no-ansiapply to both. - Verbosity —
-q,-v,-vv,-vvvapply (components->info($msg, OutputInterface::VERBOSITY_VERBOSE)etc.). With--quietdiagnostics are silent but data fromwriteJson()/writeStdout()is still printed. - Non-interactive —
--no-interactionstill returns defaults; prompts and questions are pointed at STDERR (verified non-interactively only; interactive TTY prompts are not covered by the automated tests). - Exit codes — untouched.
Unsupported / caveats
- No STDERR in the environment →
Artisan::call(),$this->artisan()in tests, queued/scheduled invocations with aBufferedOutputhave no second stream. The default'passthrough'leaves the command exactly as it would be without the trait (data and diagnostics share one stream). Return'discard'fromdiagnosticsFallback()to drop diagnostics, or'throw'to get aDiagnosticStreamUnavailableexception. The package never silently pretends the streams are separate. - Your own
initialize()— if the command defines one, call$this->initializeDiagnosticStreams($input, $output)from it. - Things that write directly to
STDOUT/echo/print,$this->outputcaptured beforeinitialize()(e.g. in a constructor), other processes (Process::run()with->tty()), and third-party code that grabs the original output are not redirected. Use$this->stdout()deliberately when you want STDOUT. $this->outputnow points at STDERR. Code inside the command that does$this->output->writeln(json_encode(...))will go to STDERR; switch it towriteJson()/writeStdout().- Laravel Prompts (
Laravel\Prompts\Prompt::setOutput()) is pointed at the error stream for the process so questions don't pollute data. That is a static, so it is process-wide after a trait-using command has started; in a normalphp artisanprocess that is one command. - Symfony progress bars created with
$this->output->createProgressBar()use the command's$this->output, so they are expected to render on STDERR as well (not covered by tests). - Termwind/Windows: not tested on Windows.
- A framework that later routes components to STDERR natively would make this package redundant — it will say so here when that happens.
Testing
composer install
vendor/bin/pest # includes real subprocess tests (separate STDOUT/STDERR pipes, shell pipes)
vendor/bin/pint --test
The suite uses Orchestra Testbench with a workbench of demo commands and launches real
testbench (Artisan) processes, because Artisan::call() cannot prove OS-level stream separation.
Contributing / License
Issues and PRs are welcome. The MIT License (MIT) — see LICENSE.