├── tools
├── .gitignore
├── psalm
│ └── composer.json
└── infection
│ └── composer.json
├── src
├── Event
│ ├── ApplicationStartup.php
│ └── ApplicationShutdown.php
├── SymfonyEventDispatcher.php
├── Output
│ └── ConsoleBufferedOutput.php
├── ErrorListener.php
├── Command
│ ├── Game.php
│ └── Serve.php
├── Application.php
├── CommandLoader.php
└── ExitCode.php
├── config
├── events-console.php
├── params-console.php
└── di-console.php
├── rector.php
├── LICENSE.md
├── CHANGELOG.md
├── composer.json
└── README.md
/tools/.gitignore:
--------------------------------------------------------------------------------
1 | /*/vendor
2 | /*/composer.lock
3 |
--------------------------------------------------------------------------------
/tools/psalm/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "require-dev": {
3 | "vimeo/psalm": "^5.26.1 || ^6.10"
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/tools/infection/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "require-dev": {
3 | "infection/infection": "^0.26 || ^0.31.9"
4 | },
5 | "config": {
6 | "allow-plugins": {
7 | "infection/extension-installer": true
8 | }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/src/Event/ApplicationStartup.php:
--------------------------------------------------------------------------------
1 | [
10 | [ErrorListener::class, 'onError'],
11 | ],
12 | ];
13 |
--------------------------------------------------------------------------------
/src/Event/ApplicationShutdown.php:
--------------------------------------------------------------------------------
1 | exitCode;
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/rector.php:
--------------------------------------------------------------------------------
1 | withPaths([
10 | __DIR__ . '/src',
11 | __DIR__ . '/tests',
12 | ])
13 | ->withRules([
14 | InlineConstructorDefaultToPropertyRector::class,
15 | ])
16 | ->withPhpSets(php80: true);
17 |
--------------------------------------------------------------------------------
/config/params-console.php:
--------------------------------------------------------------------------------
1 | [
11 | 'name' => Application::NAME,
12 | 'version' => Application::VERSION,
13 | 'autoExit' => false,
14 | 'commands' => [
15 | 'serve' => Serve::class,
16 | '|yii' => Game::class,
17 | ],
18 | 'serve' => [
19 | 'appRootPath' => null,
20 | 'options' => [
21 | 'address' => '127.0.0.1',
22 | 'port' => '8080',
23 | 'docroot' => 'public',
24 | 'router' => 'public/index.php',
25 | ],
26 | ],
27 | ],
28 | ];
29 |
--------------------------------------------------------------------------------
/src/SymfonyEventDispatcher.php:
--------------------------------------------------------------------------------
1 | dispatcher->dispatch($event);
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/Output/ConsoleBufferedOutput.php:
--------------------------------------------------------------------------------
1 | buffer;
19 |
20 | if ($clearBuffer) {
21 | $this->buffer = '';
22 | }
23 |
24 | return $content;
25 | }
26 |
27 | protected function doWrite(string $message, bool $newline): void
28 | {
29 | $this->buffer .= $message;
30 |
31 | if ($newline) {
32 | $this->buffer .= PHP_EOL;
33 | }
34 |
35 | parent::doWrite($message, $newline);
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/ErrorListener.php:
--------------------------------------------------------------------------------
1 | logger === null) {
21 | return;
22 | }
23 |
24 | $exception = $event->getError();
25 | $command = $event->getCommand();
26 |
27 | $message = sprintf(
28 | '%s: %s in %s:%s while running console command "%s".',
29 | $exception::class,
30 | $exception->getMessage(),
31 | $exception->getFile(),
32 | $exception->getLine(),
33 | $command?->getName() ?? 'unknown',
34 | );
35 |
36 | $this->logger->error($message, ['exception' => $exception]);
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Copyright © 2008 by Yii Software ()
2 | All rights reserved.
3 |
4 | Redistribution and use in source and binary forms, with or without
5 | modification, are permitted provided that the following conditions
6 | are met:
7 |
8 | * Redistributions of source code must retain the above copyright
9 | notice, this list of conditions and the following disclaimer.
10 | * Redistributions in binary form must reproduce the above copyright
11 | notice, this list of conditions and the following disclaimer in
12 | the documentation and/or other materials provided with the
13 | distribution.
14 | * Neither the name of Yii Software nor the names of its
15 | contributors may be used to endorse or promote products derived
16 | from this software without specific prior written permission.
17 |
18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
21 | FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
22 | COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
23 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
24 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
25 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
27 | LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
28 | ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
29 | POSSIBILITY OF SUCH DAMAGE.
30 |
--------------------------------------------------------------------------------
/src/Command/Game.php:
--------------------------------------------------------------------------------
1 | title('Welcome to the Guessing Game!');
28 |
29 |
30 | /** @var QuestionHelper $helper */
31 | $helper = $this->getHelper('question');
32 | $question = new Question('Please enter a number between 0 and 100: ');
33 |
34 | while (true) {
35 | $answer = (int) $helper->ask($input, $output, $question);
36 |
37 | if ($answer === $number) {
38 | $io->success('You win! You guessed the number in ' . $steps . ' steps.');
39 | return 0;
40 | }
41 |
42 | $steps++;
43 | if ($answer > $number) {
44 | $io->warning('Too high!');
45 | } else {
46 | $io->warning('Too low!');
47 | }
48 | }
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/config/di-console.php:
--------------------------------------------------------------------------------
1 | [
17 | 'class' => CommandLoader::class,
18 | '__construct()' => [
19 | 'commandMap' => $params['yiisoft/yii-console']['commands'],
20 | ],
21 | ],
22 |
23 | Serve::class => [
24 | 'class' => Serve::class,
25 | '__construct()' => [
26 | 'appRootPath' => $params['yiisoft/yii-console']['serve']['appRootPath'],
27 | 'options' => $params['yiisoft/yii-console']['serve']['options'],
28 | ],
29 | ],
30 |
31 | Application::class => [
32 | '__construct()' => [
33 | $params['yiisoft/yii-console']['name'],
34 | $params['yiisoft/yii-console']['version'],
35 | ],
36 | 'setCommandLoader()' => [Reference::to(CommandLoaderInterface::class)],
37 | 'setDispatcher()' => [Reference::to(SymfonyEventDispatcher::class)],
38 | 'setAutoExit()' => [$params['yiisoft/yii-console']['autoExit']],
39 | 'addOptions()' => [
40 | new InputOption(
41 | 'config',
42 | null,
43 | InputOption::VALUE_REQUIRED,
44 | 'Set alternative configuration name'
45 | ),
46 | ],
47 | ],
48 | ];
49 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Yii Console Change Log
2 |
3 | ## 2.4.2 under development
4 |
5 | - no changes in this release.
6 |
7 | ## 2.4.1 December 02, 2025
8 |
9 | - Eng #231: Add PHP 8.5 support (@vjik)
10 |
11 | ## 2.4.0 September 27, 2025
12 |
13 | - Chg #228: Change PHP constraint in `composer.json` to `8.0 - 8.4` (@vjik)
14 | - Enh #226: Check empty string as key in command map in `CommandLoader` validation (@vjik)
15 | - Bug #224: Fix typo in `UNAVAILABLE` exit code reason (@vjik)
16 | - Bug #227: Throw `RuntimeException` in `ServeCommand` when it wasn't possible to find out the current directory (@vjik)
17 | - Bug #230: The `serve` command no longer opens the browser by default (@vjik)
18 |
19 | ## 2.3.0 January 23, 2025
20 |
21 | - Enh #207: Add `--open` option for `serve` command (@xepozz)
22 | - Enh #207: Print possible options for `serve` command (@xepozz)
23 | - Enh #218: Explicitly mark nullable parameters (@alexander-schranz)
24 |
25 | ## 2.2.0 February 17, 2024
26 |
27 | - Enh #194: Allow to use `ErrorListiner` without logger (@vjik)
28 |
29 | ## 2.1.2 December 26, 2023
30 |
31 | - Enh #189: Add support for `symfony/console` of version `^7.0` (@vjik)
32 |
33 | ## 2.1.1 November 05, 2023
34 |
35 | - Chg #185: Rename `params.php` to `params-console.php` (@terabytesoftw)
36 |
37 | ## 2.1.0 May 28, 2023
38 |
39 | - Bug #172: Fix accepting `:` as command name separator, offer using it by default (@samdark)
40 | - Bug #179: Remove duplicate messages about server address (@samdark)
41 | - Enh #180: Enhance output of `serve` command, add `--xdebug` option for `serve` (@xepozz)
42 |
43 | ## 2.0.1 March 31, 2023
44 |
45 | - Bug #175: Fix `serve` under Windows (@samdark)
46 |
47 | ## 2.0.0 February 17, 2023
48 |
49 | - Chg #171: Adapt configuration group names to Yii conventions (@vjik)
50 | - Enh #162: Explicitly add transitive dependencies `psr/event-dispatcher` and `psr/log` (@vjik)
51 | - Enh #163: Add `workers` option to `serve` command with default of two workers under Linux (@xepozz)
52 | - Bug #168: Fix executing the `list` command with namespace (@vjik)
53 |
54 | ## 1.3.0 July 29, 2022
55 |
56 | - Chg: #159: Add collecting console command name to `ApplicationStartup` class (@xepozz)
57 |
58 | ## 1.2.0 July 21, 2022
59 |
60 | - Enh #157: Add config for `serve` command (@dood-)
61 |
62 | ## 1.1.1 July 04, 2022
63 |
64 | - Enh #156: Add support for `symfony/event-dispatcher-contracts` of version `^3.0` (@vjik)
65 |
66 | ## 1.1.0 May 03, 2022
67 |
68 | - Chg #148: Raise the minimum PHP version to 8.0 (@rustamwin)
69 | - Enh #149: Add bash completion for `serve` command, serve at 127.0.0.1 by default (@rustamwin)
70 |
71 | ## 1.0.1 February 11, 2022
72 |
73 | - Enh #141: Add support for version `^6.0` for `symfony/console` package (@devanych)
74 | - Bug #145: Add return type to `Yiisoft\Yii\Console\CommandLoader::get()` method (@devanych)
75 |
76 | ## 1.0.0 November 01, 2021
77 |
78 | - Initial release.
79 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "yiisoft/yii-console",
3 | "type": "library",
4 | "description": "Symfony console wrapper with additional features",
5 | "keywords": [
6 | "yii",
7 | "console"
8 | ],
9 | "homepage": "https://www.yiiframework.com/",
10 | "license": "BSD-3-Clause",
11 | "support": {
12 | "issues": "https://github.com/yiisoft/yii-console/issues?state=open",
13 | "source": "https://github.com/yiisoft/yii-console",
14 | "forum": "https://www.yiiframework.com/forum/",
15 | "wiki": "https://www.yiiframework.com/wiki/",
16 | "irc": "ircs://irc.libera.chat:6697/yii",
17 | "chat": "https://t.me/yii3en"
18 | },
19 | "funding": [
20 | {
21 | "type": "opencollective",
22 | "url": "https://opencollective.com/yiisoft"
23 | },
24 | {
25 | "type": "github",
26 | "url": "https://github.com/sponsors/yiisoft"
27 | }
28 | ],
29 | "require": {
30 | "php": "8.0 - 8.5",
31 | "psr/container": "^1.0 || ^2.0",
32 | "psr/event-dispatcher": "^1.0",
33 | "psr/log": "^1.0 || ^2.0 || ^3.0",
34 | "symfony/console": "^5.4 || ^6.0 || ^7.0",
35 | "symfony/event-dispatcher-contracts": "^2.2 || ^3.0",
36 | "yiisoft/friendly-exception": "^1.0"
37 | },
38 | "require-dev": {
39 | "bamarni/composer-bin-plugin": "^1.8.3",
40 | "maglnet/composer-require-checker": "^4.4",
41 | "phpunit/phpunit": "^9.6.22",
42 | "rector/rector": "^2.0.11",
43 | "yiisoft/config": "^1.5",
44 | "yiisoft/di": "^1.2.1",
45 | "yiisoft/test-support": "^3.0.2"
46 | },
47 | "autoload": {
48 | "psr-4": {
49 | "Yiisoft\\Yii\\Console\\": "src"
50 | }
51 | },
52 | "autoload-dev": {
53 | "psr-4": {
54 | "Yiisoft\\Yii\\Console\\Tests\\": "tests"
55 | }
56 | },
57 | "extra": {
58 | "bamarni-bin": {
59 | "bin-links": true,
60 | "target-directory": "tools",
61 | "forward-command": true
62 | },
63 | "config-plugin-options": {
64 | "source-directory": "config",
65 | "merge-plan-file": "../tests/environment/.merge-plan.php"
66 | },
67 | "config-plugin": {
68 | "params-console": "params-console.php",
69 | "di-console": "di-console.php",
70 | "events-console": "events-console.php"
71 | }
72 | },
73 | "config": {
74 | "sort-packages": true,
75 | "allow-plugins": {
76 | "bamarni/composer-bin-plugin": true,
77 | "composer/package-versions-deprecated": true,
78 | "yiisoft/config": true
79 | }
80 | },
81 | "scripts": {
82 | "test": "phpunit --testdox --no-interaction",
83 | "test-watch": "phpunit-watcher watch"
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/src/Application.php:
--------------------------------------------------------------------------------
1 | dispatcher = $dispatcher;
35 | parent::setDispatcher($dispatcher);
36 | }
37 |
38 | public function start(?ArgvInput $input = null): void
39 | {
40 | if ($this->dispatcher !== null && $input !== null) {
41 | $this->dispatcher->dispatch(new ApplicationStartup($input->getFirstArgument()));
42 | }
43 | }
44 |
45 | public function shutdown(int $exitCode): void
46 | {
47 | if ($this->dispatcher !== null) {
48 | $this->dispatcher->dispatch(new ApplicationShutdown($exitCode));
49 | }
50 | }
51 |
52 | public function renderThrowable(Throwable $e, OutputInterface $output): void
53 | {
54 | $output->writeln('', OutputInterface::VERBOSITY_QUIET);
55 |
56 | $this->doRenderThrowable($e, $output);
57 | }
58 |
59 | protected function doRenderThrowable(Throwable $e, OutputInterface $output): void
60 | {
61 | parent::doRenderThrowable($e, $output);
62 |
63 | // Friendly Exception support
64 | if ($e instanceof FriendlyExceptionInterface) {
65 | if ($output instanceof StyleInterface) {
66 | $output->title($e->getName());
67 | if (($solution = $e->getSolution()) !== null) {
68 | $output->note($solution);
69 | }
70 | $output->newLine();
71 | } else {
72 | $output->writeln('' . $e->getName() . '>');
73 | if (($solution = $e->getSolution()) !== null) {
74 | $output->writeln('' . $solution . '>');
75 | }
76 | $output->writeln('');
77 | }
78 | }
79 |
80 | $output->writeln($e->getTraceAsString());
81 | }
82 |
83 | public function addOptions(InputOption $options): void
84 | {
85 | $this
86 | ->getDefinition()
87 | ->addOption($options);
88 | }
89 |
90 | public function extractNamespace(string $name, ?int $limit = null): string
91 | {
92 | return parent::extractNamespace(str_replace('/', ':', $name), $limit);
93 | }
94 |
95 | public function getNamespaces(): array
96 | {
97 | $namespaces = [];
98 | foreach ($this->all() as $command) {
99 | if ($command->isHidden()) {
100 | continue;
101 | }
102 |
103 | $namespaces[] = $this->extractAllNamespaces($command->getName());
104 |
105 | /** @var string $alias */
106 | foreach ($command->getAliases() as $alias) {
107 | $namespaces[] = $this->extractAllNamespaces($alias);
108 | }
109 | }
110 |
111 | return array_values(array_unique(array_filter(array_merge([], ...$namespaces))));
112 | }
113 |
114 | /**
115 | * @return string[]
116 | */
117 | private function extractAllNamespaces(?string $name): array
118 | {
119 | if ($name === null) {
120 | return [];
121 | }
122 |
123 | $name = str_replace('/', ':', $name);
124 |
125 | $parts = explode(':', $name, -1);
126 | $namespaces = [];
127 |
128 | foreach ($parts as $part) {
129 | if (count($namespaces)) {
130 | $namespaces[] = end($namespaces) . ':' . $part;
131 | } else {
132 | $namespaces[] = $part;
133 | }
134 | }
135 |
136 | return $namespaces;
137 | }
138 | }
139 |
--------------------------------------------------------------------------------
/src/CommandLoader.php:
--------------------------------------------------------------------------------
1 | ,
24 | * hidden: bool,
25 | * }>
26 | */
27 | private array $commandMap;
28 |
29 | /**
30 | * @var string[]
31 | */
32 | private array $commandNames;
33 |
34 | /**
35 | * @param array $commandMap An array with command names as keys and service ids as values.
36 | *
37 | * @psalm-param array> $commandMap
38 | */
39 | public function __construct(private ContainerInterface $container, array $commandMap)
40 | {
41 | $this->setCommandMap($commandMap);
42 | }
43 |
44 | public function get(string $name): Command
45 | {
46 | if (!$this->has($name)) {
47 | throw new CommandNotFoundException(sprintf('Command "%s" does not exist.', $name));
48 | }
49 |
50 | $commandName = $this->commandMap[$name]['name'];
51 | $commandAliases = $this->commandMap[$name]['aliases'];
52 | $commandClass = $this->commandMap[$name]['class'];
53 | $commandHidden = $this->commandMap[$name]['hidden'];
54 |
55 | /**
56 | * @see https://github.com/yiisoft/yii-console/issues/229
57 | * @psalm-suppress DeprecatedMethod
58 | */
59 | $description = $commandClass::getDefaultDescription();
60 |
61 | if ($description === null) {
62 | return $this->getCommandInstance($name);
63 | }
64 |
65 | return new LazyCommand(
66 | $commandName,
67 | $commandAliases,
68 | $description,
69 | $commandHidden,
70 | fn () => $this->getCommandInstance($name),
71 | );
72 | }
73 |
74 | public function has(string $name): bool
75 | {
76 | return isset($this->commandMap[$name]);
77 | }
78 |
79 | public function getNames(): array
80 | {
81 | return $this->commandNames;
82 | }
83 |
84 | private function getCommandInstance(string $name): Command
85 | {
86 | $commandName = $this->commandMap[$name]['name'];
87 | $commandClass = $this->commandMap[$name]['class'];
88 | $commandAliases = $this->commandMap[$name]['aliases'];
89 |
90 | /** @var Command $command */
91 | $command = $this->container->get($commandClass);
92 |
93 | if ($command->getName() !== $commandName) {
94 | $command->setName($commandName);
95 | }
96 |
97 | if ($command->getAliases() !== $commandAliases) {
98 | $command->setAliases($commandAliases);
99 | }
100 |
101 | return $command;
102 | }
103 |
104 | /**
105 | * @psalm-param array> $commandMap
106 | */
107 | private function setCommandMap(array $commandMap): void
108 | {
109 | $this->commandMap = [];
110 | $this->commandNames = [];
111 |
112 | foreach ($commandMap as $name => $class) {
113 | $aliases = explode('|', $name);
114 |
115 | $hidden = false;
116 | if ($aliases[0] === '') {
117 | $hidden = true;
118 | array_shift($aliases);
119 | }
120 |
121 | $this->validateAliases($aliases);
122 |
123 | $primaryName = array_shift($aliases);
124 |
125 | $item = [
126 | 'name' => $primaryName,
127 | 'aliases' => $aliases,
128 | 'class' => $class,
129 | 'hidden' => $hidden,
130 | ];
131 |
132 | $this->commandMap[$primaryName] = $item;
133 | $this->commandNames[] = $primaryName;
134 |
135 | foreach ($aliases as $alias) {
136 | $this->commandMap[$alias] = $item;
137 | }
138 | }
139 | }
140 |
141 | /**
142 | * @psalm-param list $aliases
143 | *
144 | * @psalm-assert non-empty-list $aliases
145 | */
146 | private function validateAliases(array $aliases): void
147 | {
148 | if (empty($aliases)) {
149 | throw new RuntimeException('Do not allow empty command name or alias.');
150 | }
151 | foreach ($aliases as $alias) {
152 | if ($alias === '') {
153 | throw new RuntimeException('Do not allow empty command name or alias.');
154 | }
155 | }
156 | }
157 | }
158 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
Yii Console
6 |
7 |
8 |
9 | [](https://packagist.org/packages/yiisoft/yii-console)
10 | [](https://packagist.org/packages/yiisoft/yii-console)
11 | [](https://github.com/yiisoft/yii-console/actions/workflows/build.yml)
12 | [](https://codecov.io/gh/yiisoft/yii-console)
13 | [](https://dashboard.stryker-mutator.io/reports/github.com/yiisoft/yii-console/master)
14 | [](https://github.com/yiisoft/yii-console/actions?query=workflow%3A%22static+analysis%22)
15 | [](https://shepherd.dev/github/yiisoft/yii-console)
16 |
17 | Yii Console package provides a console that could be added to an application. This console is based on
18 | [Symfony Console](https://github.com/symfony/console). The following extra features are added:
19 |
20 | - lazy command loader;
21 | - `SymfonyEventDispatcher` class that allows to use any [PSR-14](https://www.php-fig.org/psr/psr-14/) compatible event
22 | dispatcher with Symfony console;
23 | - `ErrorListener` for logging console errors to any [PSR-3](https://www.php-fig.org/psr/psr-3/) compatible logger;
24 | - console command `serve` that runs PHP built-in web server;
25 | - raises events `ApplicationStartup` and `ApplicationShutdown` in console application;
26 | - class `ExitCode` that contains constants for defining console command exit codes;
27 | - `ConsoleBufferedOutput` that wraps `ConsoleOutput` and buffers console output.
28 |
29 | ## Requirements
30 |
31 | - PHP 8.0 - 8.5.
32 |
33 | ## Installation
34 |
35 | The package could be installed with [Composer](https://getcomposer.org):
36 |
37 | ```shell
38 | composer require yiisoft/yii-console
39 | ```
40 |
41 | ## General usage
42 |
43 | In case you use one of Yii 3 standard application templates, console could be accessed as `./yii `.
44 |
45 | If not, then in the simplest use case in your console entry script do the following:
46 |
47 | ```php
48 | #!/usr/bin/env php
49 | setCommandLoader(new CommandLoader(
63 | // Any container implementing `Psr\Container\ContainerInterface` for example:
64 | new Container(ContainerConfig::create()),
65 | // An array with command names as keys and service IDs as values:
66 | ['my/custom' => MyCustomCommand::class],
67 | ));
68 |
69 | $app->run();
70 | ```
71 |
72 | Since `\Yiisoft\Yii\Console\CommandLoader` uses lazy loading of commands, it's necessary
73 | to specify the name and description in static properties when creating a command:
74 |
75 | ```php
76 | use Symfony\Component\Console\Attribute\AsCommand;
77 | use Symfony\Component\Console\Command\Command;
78 | use Yiisoft\Yii\Console\ExitCode;
79 |
80 |
81 | #[AsCommand(
82 | name: 'my:custom',
83 | description: 'Description of my custom command.'
84 | )]
85 | final class MyCustomCommand extends Command
86 | {
87 | protected function configure(): void
88 | {
89 | // ...
90 | }
91 |
92 | protected function execute(InputInterface $input, OutputInterface $output): int
93 | {
94 | // ...
95 | return ExitCode::OK;
96 | }
97 | }
98 | ```
99 |
100 | Run the console entry script with your command:
101 |
102 | ```shell
103 | your-console-entry-script my/custom
104 | ```
105 |
106 | > When naming commands use `:` as a separator. For example: `user:create`, `user:delete`, etc.
107 |
108 | Since the package is based on [Symfony Console component](https://symfony.com/doc/current/components/console.html),
109 | refer to its documentation for details on how to use the binary and create your own commands.
110 |
111 | ### Aliases and hidden commands
112 |
113 | To configure commands, set the names and aliases in `\Yiisoft\Yii\Console\CommandLoader` configuration.
114 | Names and aliases from the command class itself are always ignored.
115 |
116 | The command can be marked as hidden by prefixing its name with `|`.
117 |
118 | ```php
119 | 'yiisoft/yii-console' => [
120 | 'commands' => [
121 | 'hello' => Hello::class, // name: 'hello', aliases: [], hidden: false
122 | 'start|run|s|r' => Run::class, // name: 'start', aliases: ['run', 's', 'r'], hidden: false
123 | '|hack|h' => Hack::class, // name: 'hack', aliases: ['h'], hidden: true
124 | ],
125 | ],
126 | ```
127 |
128 | ### Runs PHP built-in web server
129 |
130 | You can start local built-in web development server using the command:
131 |
132 | ```shell
133 | ./yii serve
134 | ```
135 |
136 | Your application will be accessible in your web browser at by default.
137 | To configure default settings, set the options in `\Yiisoft\Yii\Console\CommandLoader` configuration.
138 |
139 | ```php
140 | 'yiisoft/yii-console' => [
141 | 'serve' => [
142 | 'appRootPath' => null,
143 | 'options' => [
144 | 'address' => '127.0.0.1',
145 | 'port' => '8080',
146 | 'docroot' => 'public',
147 | 'router' => 'public/index.php',
148 | ],
149 | ],
150 | ],
151 | ```
152 |
153 | Alternatively, you can pass the settings through the console options.
154 |
155 | > Tip: To run a web server with XDebug enabled, pass `--xdebug 1` to the command.
156 |
157 | To see the available options, run `./yii serve --help`.
158 |
159 | ## Documentation
160 |
161 | - [Internals](docs/internals.md)
162 |
163 | If you need help or have a question, the [Yii Forum](https://forum.yiiframework.com/c/yii-3-0/63) is a good place for that.
164 | You may also check out other [Yii Community Resources](https://www.yiiframework.com/community).
165 |
166 | ## License
167 |
168 | The Yii Console is free software. It is released under the terms of the BSD License.
169 | Please see [`LICENSE`](./LICENSE.md) for more information.
170 |
171 | Maintained by [Yii Software](https://www.yiiframework.com/).
172 |
173 | ## Support the project
174 |
175 | [](https://opencollective.com/yiisoft)
176 |
177 | ## Follow updates
178 |
179 | [](https://www.yiiframework.com/)
180 | [](https://twitter.com/yiiframework)
181 | [](https://t.me/yii3en)
182 | [](https://www.facebook.com/groups/yiitalk)
183 | [](https://yiiframework.com/go/slack)
184 |
--------------------------------------------------------------------------------
/src/ExitCode.php:
--------------------------------------------------------------------------------
1 | isAllowedToPerformAction()) {
18 | * $this->stderr('Error: ' . ExitCode::getReason(ExitCode::NOPERM));
19 | * return ExitCode::NOPERM;
20 | * }
21 | *
22 | * // do something
23 | *
24 | * return ExitCode::OK;
25 | * }
26 | * ```
27 | *
28 | * @see https://man.openbsd.org/sysexits
29 | *
30 | * @final See https://github.com/yiisoft/yii-console/issues/225
31 | */
32 | class ExitCode
33 | {
34 | /**
35 | * The command completed successfully.
36 | *
37 | * @psalm-suppress MissingClassConstType It needs while supported PHP version is <=8.2
38 | */
39 | public const OK = 0;
40 |
41 | /**
42 | * The command exited with an error code that says nothing about the error.
43 | *
44 | * @psalm-suppress MissingClassConstType It needs while supported PHP version is <=8.2
45 | */
46 | public const UNSPECIFIED_ERROR = 1;
47 |
48 | /**
49 | * The command was used incorrectly, e.g., with the wrong number of
50 | * arguments, a bad flag, a bad syntax in a parameter, or whatever.
51 | *
52 | * @psalm-suppress MissingClassConstType It needs while supported PHP version is <=8.2
53 | */
54 | public const USAGE = 64;
55 |
56 | /**
57 | * The input data was incorrect in some way. This should only be used for
58 | * user's data and not system files.
59 | *
60 | * @psalm-suppress MissingClassConstType It needs while supported PHP version is <=8.2
61 | */
62 | public const DATAERR = 65;
63 |
64 | /**
65 | * An input file (not a system file) did not exist or was not readable.
66 | * This could also include errors like ``No message'' to a mailer (if it
67 | * cared to catch it).
68 | *
69 | * @psalm-suppress MissingClassConstType It needs while supported PHP version is <=8.2
70 | */
71 | public const NOINPUT = 66;
72 |
73 | /**
74 | * The user specified did not exist. This might be used for mail addresses
75 | * or remote logins.
76 | *
77 | * @psalm-suppress MissingClassConstType It needs while supported PHP version is <=8.2
78 | */
79 | public const NOUSER = 67;
80 |
81 | /**
82 | * The host specified did not exist. This is used in mail addresses or
83 | * network requests.
84 | *
85 | * @psalm-suppress MissingClassConstType It needs while supported PHP version is <=8.2
86 | */
87 | public const NOHOST = 68;
88 |
89 | /**
90 | * A service is unavailable. This can occur if a support program or file
91 | * does not exist. This can also be used as a catchall message when
92 | * something you wanted to do does not work, but you do not know why.
93 | *
94 | * @psalm-suppress MissingClassConstType It needs while supported PHP version is <=8.2
95 | */
96 | public const UNAVAILABLE = 69;
97 |
98 | /**
99 | * An internal software error has been detected. This should be limited to
100 | * non-operating system related errors as possible.
101 | *
102 | * @psalm-suppress MissingClassConstType It needs while supported PHP version is <=8.2
103 | */
104 | public const SOFTWARE = 70;
105 |
106 | /**
107 | * An operating system error has been detected. This is intended to be
108 | * used for such things as ``cannot fork'', ``cannot create pipe'', or the
109 | * like. It includes things like getuid returning a user that does not
110 | * exist in the passwd file.
111 | *
112 | * @psalm-suppress MissingClassConstType It needs while supported PHP version is <=8.2
113 | */
114 | public const OSERR = 71;
115 |
116 | /**
117 | * Some system file (e.g., /etc/passwd, /var/run/utx.active, etc.) does not
118 | * exist, cannot be opened, or has some sort of error (e.g., syntax error).
119 | *
120 | * @psalm-suppress MissingClassConstType It needs while supported PHP version is <=8.2
121 | */
122 | public const OSFILE = 72;
123 |
124 | /**
125 | * A (user specified) output file cannot be created.
126 | *
127 | * @psalm-suppress MissingClassConstType It needs while supported PHP version is <=8.2
128 | */
129 | public const CANTCREAT = 73;
130 |
131 | /**
132 | * An error occurred while doing I/O on some file.
133 | *
134 | * @psalm-suppress MissingClassConstType It needs while supported PHP version is <=8.2
135 | */
136 | public const IOERR = 74;
137 |
138 | /**
139 | * Temporary failure, indicating something that is not really an error. In
140 | * sendmail, this means that a mailer (e.g.) could not create a connection,
141 | * and the request should be reattempted later.
142 | *
143 | * @psalm-suppress MissingClassConstType It needs while supported PHP version is <=8.2
144 | */
145 | public const TEMPFAIL = 75;
146 |
147 | /**
148 | * The remote system returned something that was ``not possible'' during a
149 | * protocol exchange.
150 | *
151 | * @psalm-suppress MissingClassConstType It needs while supported PHP version is <=8.2
152 | */
153 | public const PROTOCOL = 76;
154 |
155 | /**
156 | * You did not have sufficient permission to perform the operation. This
157 | * is not intended for file system problems, which should use NOINPUT or
158 | * CANTCREAT, but rather for higher level permissions.
159 | *
160 | * @psalm-suppress MissingClassConstType It needs while supported PHP version is <=8.2
161 | */
162 | public const NOPERM = 77;
163 |
164 | /**
165 | * Something was found in an unconfigured or misconfigured state.
166 | *
167 | * @psalm-suppress MissingClassConstType It needs while supported PHP version is <=8.2
168 | */
169 | public const CONFIG = 78;
170 |
171 | /**
172 | * @var array A map of reason descriptions for exit codes.
173 | */
174 | public static array $reasons = [
175 | self::OK => 'Success',
176 | self::UNSPECIFIED_ERROR => 'Unspecified error',
177 | self::USAGE => 'Incorrect usage, argument or option error',
178 | self::DATAERR => 'Error in input data',
179 | self::NOINPUT => 'Input file not found or unreadable',
180 | self::NOUSER => 'User not found',
181 | self::NOHOST => 'Host not found',
182 | self::UNAVAILABLE => 'A required service is unavailable',
183 | self::SOFTWARE => 'Internal error',
184 | self::OSERR => 'Error making system call or using OS service',
185 | self::OSFILE => 'Error accessing system file',
186 | self::CANTCREAT => 'Cannot create output file',
187 | self::IOERR => 'I/O error',
188 | self::TEMPFAIL => 'Temporary failure',
189 | self::PROTOCOL => 'Unexpected remote service behavior',
190 | self::NOPERM => 'Insufficient permissions',
191 | self::CONFIG => 'Configuration error',
192 | ];
193 |
194 | /**
195 | * Returns a short reason text for the given exit code.
196 | *
197 | * This method uses {@see $reasons} to determine the reason for an exit code.
198 | *
199 | * @param int $exitCode One of the constants defined in this class.
200 | *
201 | * @return string The reason text, or `"Unknown exit code"` if the code is not listed in {@see $reasons}.
202 | */
203 | public static function getReason(int $exitCode): string
204 | {
205 | return static::$reasons[$exitCode] ?? 'Unknown exit code';
206 | }
207 | }
208 |
--------------------------------------------------------------------------------
/src/Command/Serve.php:
--------------------------------------------------------------------------------
1 | defaultAddress = $options['address'] ?? '127.0.0.1';
51 | $this->defaultPort = $options['port'] ?? '8080';
52 | $this->defaultDocroot = $options['docroot'] ?? 'public';
53 | $this->defaultRouter = $options['router'] ?? 'public/index.php';
54 | $this->defaultWorkers = (int) ($options['workers'] ?? 2);
55 |
56 | parent::__construct();
57 | }
58 |
59 | public function configure(): void
60 | {
61 | $this
62 | ->setHelp(
63 | 'In order to access server from remote machines use 0.0.0.0:8000. That is especially useful when running server in a virtual machine.'
64 | )
65 | ->addArgument('address', InputArgument::OPTIONAL, 'Host to serve at', $this->defaultAddress)
66 | ->addOption('port', 'p', InputOption::VALUE_OPTIONAL, 'Port to serve at', $this->defaultPort)
67 | ->addOption(
68 | 'docroot',
69 | 't',
70 | InputOption::VALUE_OPTIONAL,
71 | 'Document root to serve from',
72 | $this->defaultDocroot
73 | )
74 | ->addOption('router', 'r', InputOption::VALUE_OPTIONAL, 'Path to router script', $this->defaultRouter)
75 | ->addOption(
76 | 'workers',
77 | 'w',
78 | InputOption::VALUE_OPTIONAL,
79 | 'Workers number the server will start with',
80 | $this->defaultWorkers
81 | )
82 | ->addOption('env', 'e', InputOption::VALUE_OPTIONAL, 'It is only used for testing.')
83 | ->addOption('open', 'o', InputOption::VALUE_OPTIONAL, 'Opens the serving server in the default browser.', false)
84 | ->addOption('xdebug', 'x', InputOption::VALUE_OPTIONAL, 'Enables XDEBUG session.', false);
85 | }
86 |
87 | public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
88 | {
89 | if ($input->mustSuggestArgumentValuesFor('address')) {
90 | $suggestions->suggestValues(['localhost', '127.0.0.1', '0.0.0.0']);
91 | return;
92 | }
93 | }
94 |
95 | protected function execute(InputInterface $input, OutputInterface $output): int
96 | {
97 | $io = new SymfonyStyle($input, $output);
98 | $io->title('Yii3 Development Server');
99 | $io->writeln('https://yiiframework.com' . "\n");
100 |
101 | /** @var string $address */
102 | $address = $input->getArgument('address');
103 |
104 | /** @var string $router */
105 | $router = $input->getOption('router');
106 | $workers = (int) $input->getOption('workers');
107 |
108 | /** @var string $port */
109 | $port = $input->getOption('port');
110 |
111 | /** @var string $docroot */
112 | $docroot = $input->getOption('docroot');
113 |
114 | if ($router === $this->defaultRouter && !file_exists($this->defaultRouter)) {
115 | $io->warning(
116 | 'Default router "' . $this->defaultRouter . '" does not exist. Serving without router. URLs with dots may fail.'
117 | );
118 | $router = null;
119 | }
120 |
121 | /** @var string $env */
122 | $env = $input->getOption('env');
123 |
124 | $documentRoot = $this->getRootPath() . DIRECTORY_SEPARATOR . $docroot;
125 |
126 | if (!str_contains($address, ':')) {
127 | $address .= ':' . $port;
128 | }
129 |
130 | if (!is_dir($documentRoot)) {
131 | $io->error("Document root \"$documentRoot\" does not exist.");
132 | return self::EXIT_CODE_NO_DOCUMENT_ROOT;
133 | }
134 |
135 | if ($this->isAddressTaken($address)) {
136 | $io->error("http://$address is taken by another process.");
137 | return self::EXIT_CODE_ADDRESS_TAKEN_BY_ANOTHER_PROCESS;
138 | }
139 |
140 | if ($router !== null && !file_exists($router)) {
141 | $io->error("Routing file \"$router\" does not exist.");
142 | return self::EXIT_CODE_NO_ROUTING_FILE;
143 | }
144 |
145 | $command = [];
146 |
147 | $isLinux = DIRECTORY_SEPARATOR !== '\\';
148 |
149 | if ($isLinux) {
150 | $command[] = 'PHP_CLI_SERVER_WORKERS=' . $workers;
151 | }
152 |
153 | $xDebugInstalled = extension_loaded('xdebug');
154 | $xDebugEnabled = $isLinux && $xDebugInstalled && $input->hasOption('xdebug') && $input->getOption('xdebug') === null;
155 |
156 | if ($xDebugEnabled) {
157 | $command[] = 'XDEBUG_MODE=debug XDEBUG_TRIGGER=yes';
158 | }
159 | $outputTable = [];
160 | $outputTable[] = ['PHP', PHP_VERSION];
161 |
162 | if ($xDebugInstalled) {
163 | /**
164 | * @var string $xDebugVersion Here we know that xDebug is installed, so `phpversion` will not return false.
165 | */
166 | $xDebugVersion = phpversion('xdebug');
167 | $xDebugLine = sprintf(
168 | '%s, %s',
169 | $xDebugVersion,
170 | $xDebugEnabled ? ' Enabled >' : ' Disabled >',
171 | );
172 | } else {
173 | $xDebugLine = 'Not installed>';
174 | }
175 | $outputTable[] = ['xDebug', $xDebugLine, '--xdebug'];
176 |
177 | $outputTable[] = ['Workers', $isLinux ? $workers : 'Not supported', '--workers, -w'];
178 | $outputTable[] = ['Address', $address];
179 | $outputTable[] = ['Document root', $documentRoot, '--docroot, -t'];
180 | $outputTable[] = ($router ? ['Routing file', $router, '--router, -r'] : []);
181 |
182 | $io->table(['Configuration', null, 'Options'], $outputTable);
183 |
184 | $command[] = '"' . PHP_BINARY . '"' . " -S $address -t \"$documentRoot\" $router";
185 | $command = implode(' ', $command);
186 |
187 | $output->writeln([
188 | 'Executing: ',
189 | sprintf('%s>', $command),
190 | ], OutputInterface::VERBOSITY_VERBOSE);
191 |
192 | $io->success('Quit the server with CTRL-C or COMMAND-C.');
193 |
194 | if ($env === 'test') {
195 | return ExitCode::OK;
196 | }
197 |
198 | $openInBrowser = $input->hasOption('open') && $input->getOption('open') === null;
199 |
200 | if ($openInBrowser) {
201 | passthru('open http://' . $address);
202 | }
203 | passthru($command, $result);
204 |
205 | return $result;
206 | }
207 |
208 | /**
209 | * @param string $address The server address.
210 | *
211 | * @return bool If address is already in use.
212 | */
213 | private function isAddressTaken(string $address): bool
214 | {
215 | [$hostname, $port] = explode(':', $address);
216 | $fp = @fsockopen($hostname, (int) $port, $errno, $errstr, 3);
217 |
218 | if ($fp === false) {
219 | return false;
220 | }
221 |
222 | fclose($fp);
223 | return true;
224 | }
225 |
226 | private function getRootPath(): string
227 | {
228 | if ($this->appRootPath !== null) {
229 | return rtrim($this->appRootPath, DIRECTORY_SEPARATOR);
230 | }
231 |
232 | $currentDirectory = getcwd();
233 | if ($currentDirectory === false) {
234 | throw new RuntimeException('Failed to get current working directory.');
235 | }
236 |
237 | return $currentDirectory;
238 | }
239 | }
240 |
--------------------------------------------------------------------------------