diff --git a/CHANGELOG.md b/CHANGELOG.md
index a2ac1ec..1b9b5d7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,23 @@ All notable changes to `laranail/console` are documented in this file.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [Unreleased]
+
+### Fixed
+
+- **Deprecated-alias warnings went to stdout, not stderr.** `Illuminate\Console\Command::run()`
+ wraps the terminal output in an `OutputStyle` before `initialize()` runs, and `OutputStyle` is
+ not a `ConsoleOutputInterface`, so `WarnsOnDeprecatedAlias` never found the error stream and
+ the warning became the first line of stdout. A script parsing a bare alias's output got an
+ extra line. The warning now goes to stderr on a real terminal, as documented; under
+ `Artisan::call()`, which has no separate error stream, it is still part of the captured
+ output.
+- **`ConsoleWriter` errors went to stdout inside a command**, for the same reason: `error()`,
+ `danger()` and `toStderr()` are routed to stderr only when the writer can see a console
+ output, and the writer `InteractsWithConsoleWriter` builds is handed the command's
+ `OutputStyle`. Both now resolve the error stream through `Tools\Support\ErrorOutput`, which
+ unwraps the style first.
+
## [0.1.5] - 2026-10-05
### Added
@@ -180,4 +197,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Initial public release.
-[Unreleased]: https://github.com/laranail/console/compare/v0.1.4...HEAD
+[Unreleased]: https://github.com/laranail/console/compare/v0.1.5...HEAD
diff --git a/docs/tools/commands.md b/docs/tools/commands.md
index 2b796d7..4b5e563 100644
--- a/docs/tools/commands.md
+++ b/docs/tools/commands.md
@@ -270,7 +270,8 @@ Deprecated: [make:crud] is a deprecated alias and will be removed in the next mi
```
The canonical name and the aliases in `$commandAliases` print nothing. On a real terminal
-the line goes to stderr, so piped output is unchanged.
+the line goes to stderr, so piped output is unchanged. `Artisan::call()` captures into a single
+buffer with no error stream, so there the line is part of `Artisan::output()`.
This is the only way a bare generic name may stay registered beside a vendor-scoped one: the
warning names the replacement, which makes the alias a migration path rather than a second
diff --git a/docs/tools/writing.md b/docs/tools/writing.md
index 0bafda1..ef87733 100644
--- a/docs/tools/writing.md
+++ b/docs/tools/writing.md
@@ -50,7 +50,9 @@ unchanged.
Ready-to-use, coloured glyph + message (rendered via
[`StatusLine`](widgets.md); glyphs degrade to ASCII without Unicode). `error()` and
-`danger()` are written to **stderr**.
+`danger()` are written to **stderr** — including from a command's own writer, whose output
+Laravel wraps in an `OutputStyle`. An output with no error stream, such as the buffer
+`Artisan::call()` uses, receives them instead.
```php
$w->success('Deployed'); // ✓ green ([OK] without Unicode)
diff --git a/src/Tools/Commands/Concerns/WarnsOnDeprecatedAlias.php b/src/Tools/Commands/Concerns/WarnsOnDeprecatedAlias.php
index 677483c..0e0ac5a 100644
--- a/src/Tools/Commands/Concerns/WarnsOnDeprecatedAlias.php
+++ b/src/Tools/Commands/Concerns/WarnsOnDeprecatedAlias.php
@@ -6,7 +6,7 @@
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
-use Symfony\Component\Console\Output\ConsoleOutputInterface;
+use Simtabi\Laranail\Console\Tools\Support\ErrorOutput;
/**
* Keeps a command's old names working as DEPRECATED aliases that say so when used.
@@ -37,7 +37,9 @@
* token for `php artisan`, `Artisan::call()` and `$this->call()` alike, while `getName()` is
* always the canonical name. Symfony calls `initialize()` after binding the input and before
* `interact()`, so the warning prints before any prompt. On a real terminal it goes to stderr,
- * so piped output is unchanged.
+ * so piped output is unchanged: the output reaching `initialize()` is Laravel's `OutputStyle`
+ * wrapper, which {@see ErrorOutput} sees through. Under `Artisan::call()` there is no separate
+ * error stream, and the warning is captured with the rest of the output.
*
* @api Stable extension point (SemVer-covered).
*/
@@ -87,9 +89,7 @@ protected function initialize(InputInterface $input, OutputInterface $output): v
return;
}
- $target = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output;
-
- $target->writeln(sprintf(
+ ErrorOutput::of($output)->writeln(sprintf(
'Deprecated: [%s] is a deprecated alias and will be removed in the next minor after 0.1. Use [%s] instead.',
$invokedAs,
(string) $this->getName(),
diff --git a/src/Tools/Services/ConsoleWriter.php b/src/Tools/Services/ConsoleWriter.php
index 810c731..e5d0236 100644
--- a/src/Tools/Services/ConsoleWriter.php
+++ b/src/Tools/Services/ConsoleWriter.php
@@ -8,9 +8,9 @@
use Simtabi\Laranail\Console\Tools\Support\Symbols;
use Symfony\Component\Console\Output\OutputInterface;
use Simtabi\Laranail\Console\Tools\Widgets\StatusLine;
+use Simtabi\Laranail\Console\Tools\Support\ErrorOutput;
use Simtabi\Laranail\Console\Tools\Support\Capabilities;
use Symfony\Component\Console\Formatter\OutputFormatter;
-use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\Console\Formatter\OutputFormatterStyle;
use Symfony\Component\Console\Formatter\OutputFormatterInterface;
@@ -362,9 +362,7 @@ private function with(callable $mutate): self
private function status(string $status, array $lines): self
{
$forceErr = $status === 'error' || $status === 'danger';
- $stream = ($forceErr || $this->stderr) && $this->output instanceof ConsoleOutputInterface
- ? $this->output->getErrorOutput()
- : $this->output;
+ $stream = $forceErr || $this->stderr ? ErrorOutput::of($this->output) : $this->output;
$statusLine = StatusLine::make($this->caps());
@@ -396,11 +394,7 @@ private function caps(): Capabilities
private function stream(): OutputInterface
{
- if ($this->stderr && $this->output instanceof ConsoleOutputInterface) {
- return $this->output->getErrorOutput();
- }
-
- return $this->output;
+ return $this->stderr ? ErrorOutput::of($this->output) : $this->output;
}
private function formatter(): OutputFormatterInterface
diff --git a/src/Tools/Support/ErrorOutput.php b/src/Tools/Support/ErrorOutput.php
new file mode 100644
index 0000000..778138f
--- /dev/null
+++ b/src/Tools/Support/ErrorOutput.php
@@ -0,0 +1,33 @@
+getOutput() : $output;
+
+ return $inner instanceof ConsoleOutputInterface ? $inner->getErrorOutput() : $output;
+ }
+}
diff --git a/tests/Tools/Unit/Commands/StderrRoutingTest.php b/tests/Tools/Unit/Commands/StderrRoutingTest.php
new file mode 100644
index 0000000..0eac3bf
--- /dev/null
+++ b/tests/Tools/Unit/Commands/StderrRoutingTest.php
@@ -0,0 +1,193 @@
+memory(), decorated: false);
+ $this->stderr = new StreamOutput($this->memory(), decorated: false);
+ }
+
+ public function getErrorOutput(): OutputInterface
+ {
+ return $this->stderr;
+ }
+
+ public function setErrorOutput(OutputInterface $error): void
+ {
+ $this->stderr = $error;
+ }
+
+ public function section(): ConsoleSectionOutput
+ {
+ throw new LogicException('Sections are not used by these tests.');
+ }
+
+ public function stdout(): string
+ {
+ return $this->read($this);
+ }
+
+ public function stderr(): string
+ {
+ return $this->stderr instanceof StreamOutput ? $this->read($this->stderr) : '';
+ }
+
+ /** @return resource */
+ private function memory()
+ {
+ $stream = fopen('php://memory', 'w+b');
+
+ if ($stream === false) {
+ throw new LogicException('Cannot open php://memory.');
+ }
+
+ return $stream;
+ }
+
+ private function read(StreamOutput $output): string
+ {
+ $stream = $output->getStream();
+ rewind($stream);
+
+ return (string) stream_get_contents($stream);
+ }
+}
+
+/**
+ * A command on the package's base with one bare deprecated alias.
+ */
+final class StderrAliasCommand extends Command
+{
+ use SupportsNamespacedNames;
+
+ protected $signature = 'laranail::console-test.stderr-alias';
+
+ protected $description = 'Deprecated-alias stream test command';
+
+ /** @var list */
+ protected array $deprecatedCommandAliases = ['console-test:stderr-old'];
+
+ public function handle(): int
+ {
+ $this->line('handled');
+
+ return self::SUCCESS;
+ }
+}
+
+/**
+ * Emits a ConsoleWriter error line from inside a command, through the command's own output.
+ */
+final class StderrWriterCommand extends IlluminateCommand
+{
+ use InteractsWithConsoleWriter;
+ use SupportsNamespacedNames;
+
+ protected $signature = 'laranail::console-test.stderr-writer';
+
+ protected $description = 'ConsoleWriter stderr routing test command';
+
+ public function handle(): int
+ {
+ $this->consoleWriter()->line('result');
+ $this->consoleWriter()->error('boom');
+ $this->consoleWriter()->toStderr()->line('diagnostic');
+
+ return self::SUCCESS;
+ }
+}
+
+/**
+ * Stream routing through the real `Illuminate\Console\Command::run()` path.
+ *
+ * `run()` wraps the output it is given in an `OutputStyle` before `initialize()` and `handle()`
+ * see it, so these tests hand a command an output with a separate error stream and run it the way
+ * `php artisan` does. A bare Symfony output would skip the wrapper and pass on the defect.
+ */
+final class StderrRoutingTest extends TestCase
+{
+ private const string WARNING = 'Deprecated: [console-test:stderr-old] is a deprecated alias and will be removed in the next minor after 0.1. Use [laranail::console-test.stderr-alias] instead.';
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $kernel = $this->app->make(Kernel::class);
+ $kernel->registerCommand(new StderrAliasCommand);
+ $kernel->registerCommand(new StderrWriterCommand);
+ }
+
+ protected function tearDown(): void
+ {
+ Capabilities::clearFake();
+ parent::tearDown();
+ }
+
+ public function test_a_deprecated_alias_warns_on_stderr_and_leaves_stdout_unchanged(): void
+ {
+ $output = new SplitStreamOutput;
+
+ self::assertSame(0, $this->runCommand('console-test:stderr-old', $output));
+
+ self::assertSame('handled' . PHP_EOL, $output->stdout());
+ self::assertSame(self::WARNING . PHP_EOL, $output->stderr());
+ }
+
+ public function test_stdout_of_the_alias_matches_stdout_of_the_canonical_name(): void
+ {
+ $byAlias = new SplitStreamOutput;
+ $byName = new SplitStreamOutput;
+
+ $this->runCommand('console-test:stderr-old', $byAlias);
+ $this->runCommand('laranail::console-test.stderr-alias', $byName);
+
+ self::assertSame($byName->stdout(), $byAlias->stdout());
+ self::assertSame('', $byName->stderr());
+ }
+
+ public function test_console_writer_errors_and_to_stderr_reach_stderr_from_a_command(): void
+ {
+ Capabilities::fake(unicode: false);
+ $output = new SplitStreamOutput;
+
+ self::assertSame(0, $this->runCommand('laranail::console-test.stderr-writer', $output));
+
+ self::assertSame('result' . PHP_EOL, $output->stdout());
+ self::assertStringContainsString('boom', $output->stderr());
+ self::assertStringContainsString('diagnostic', $output->stderr());
+ }
+
+ private function runCommand(string $invokedAs, SplitStreamOutput $output): int
+ {
+ $command = Artisan::all()[$invokedAs];
+ self::assertInstanceOf(IlluminateCommand::class, $command);
+
+ return $command->run(new ArrayInput(['command' => $invokedAs]), $output);
+ }
+}