├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer.json └── src ├── Attributes ├── Argument.php ├── ArtisanCommand.php └── Option.php ├── Casts ├── ArrayCaster.php ├── EnumCaster.php └── ModelCaster.php ├── Concerns ├── UsesConsoleToolkit.php └── UsesInputValidation.php ├── ConsoleToolkit.php ├── Contracts ├── Caster.php └── ConsoleInput.php ├── Enums └── ConsoleInputType.php ├── Exceptions ├── InvalidTypeException.php └── ValidationException.php ├── LaravelConsoleToolkitServiceProvider.php ├── Reflections ├── ArgumentReflection.php ├── CommandReflection.php ├── InputReflection.php └── OptionReflection.php ├── Rules └── Enum.php └── Transfers ├── InputErrorData.php └── Validation.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `laravel-console-toolkit` will be documented in this file. 4 | 5 | ## v0.2.0 - 2023-05-07 6 | 7 | ### Now compatible wit Laravel 10 8 | 9 | ### What's Changed 10 | 11 | - Bump actions/checkout from 2 to 3 by @dependabot in https://github.com/thettler/laravel-console-toolkit/pull/2 12 | - Bump dependabot/fetch-metadata from 1.3.0 to 1.3.1 by @dependabot in https://github.com/thettler/laravel-console-toolkit/pull/3 13 | - Bump dependabot/fetch-metadata from 1.3.1 to 1.3.3 by @dependabot in https://github.com/thettler/laravel-console-toolkit/pull/4 14 | - Bump dependabot/fetch-metadata from 1.3.3 to 1.3.4 by @dependabot in https://github.com/thettler/laravel-console-toolkit/pull/5 15 | - Bump dependabot/fetch-metadata from 1.3.4 to 1.3.5 by @dependabot in https://github.com/thettler/laravel-console-toolkit/pull/6 16 | - Bump ramsey/composer-install from 1 to 2 by @dependabot in https://github.com/thettler/laravel-console-toolkit/pull/7 17 | - Bump dependabot/fetch-metadata from 1.3.5 to 1.3.6 by @dependabot in https://github.com/thettler/laravel-console-toolkit/pull/10 18 | - Bump dependabot/fetch-metadata from 1.3.6 to 1.4.0 by @dependabot in https://github.com/thettler/laravel-console-toolkit/pull/12 19 | - Make compatible with Laravel 10 by @thettler in https://github.com/thettler/laravel-console-toolkit/pull/13 20 | 21 | ### New Contributors 22 | 23 | - @thettler made their first contribution in https://github.com/thettler/laravel-console-toolkit/pull/13 24 | 25 | **Full Changelog**: https://github.com/thettler/laravel-console-toolkit/compare/0.1.0...0.2.0 26 | 27 | ## v0.1.1 - 2022-11-22 28 | 29 | - Fixes Issue described in #8 30 | 31 | ## 0.1.0 - 2022-03-1 32 | 33 | - initial release 34 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) thettler 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Console Toolkit 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/thettler/laravel-console-toolkit.svg?style=flat-square)](https://packagist.org/packages/thettler/laravel-console-toolkit) 4 | [![GitHub Tests Action Status](https://github.com/thettler/laravel-console-toolkit/actions/workflows/run-tests.yml/badge.svg)](https://github.com/thettler/laravel-console-toolkit/actions/workflows/run-tests.yml) 5 | [![GitHub Code Style Action Status](https://github.com/thettler/laravel-console-toolkit/actions/workflows/php-cs-fixer.yml/badge.svg)](https://github.com/thettler/laravel-console-toolkit/actions/workflows/php-cs-fixer.yml) 6 | [![PHPStan](https://github.com/thettler/laravel-console-toolkit/actions/workflows/phpstan.yml/badge.svg)](https://github.com/thettler/laravel-console-toolkit/actions/workflows/phpstan.yml) 7 | [![Total Downloads](https://img.shields.io/packagist/dt/thettler/laravel-console-toolkit.svg?style=flat-square)](https://packagist.org/packages/thettler/laravel-console-toolkit) 8 | [![PHP Version](https://img.shields.io/packagist/php-v/thettler/laravel-console-toolkit?style=flat-square)](https://packagist.org/packages/thettler/laravel-console-toolkit) 9 | 10 | 11 | ![Header Image](/.github/header_img.png) 12 | 13 | This package makes it even easier to write maintainable and expressive Artisan commands, with argument/option casting, 14 | validation and autoAsk. Also, it lets you define your arguments/options with simple properties and attributes for better 15 | ide support and static analysis. And all this with a single trait. 16 | 17 | ## 🤯 Features 18 | 19 | All the features: 20 | 21 | | Support | Name | Description | 22 | |:-------:|:----------------------|--------------------------------------------------------------------------------------------------------------------------| 23 | | ✅ | Laravel Features | Supports everything laravel can do | 24 | | ✅ | Attribute Syntax | Use PHP-Attributes to automatically define your inputs based on types | 25 | | ✅ | Casting | Automatically cast your inputs to Enums, Models, Objects or anything you want | 26 | | ✅ | Validation | Use the Laravel Validator to validate the inputs from the console | 27 | | ✅ | Auto Ask | If the user provides an invalid value toolkit will ask again for a valid value without the need to run the command again | 28 | | ✅ | Negatable Options | Options can be specified as opposites: --dry or --no-dry | 29 | | ✅ | Option required Value | Options can have required values | 30 | 31 | ## :purple_heart: Support me 32 | 33 | Visit my blog on [https://bitbench.dev](https://bitbench.dev) or follow me on Social Media 34 | [Twitter @bitbench](https://twitter.com/bitbench) 35 | [Instagram @bitbench.dev](https://www.instagram.com/bitbench.dev/) 36 | 37 | ## :package: Installation 38 | 39 | You can install the package via composer: 40 | 41 | ```bash 42 | composer require thettler/laravel-console-toolkit 43 | ``` 44 | 45 | ## :wrench: Usage 46 | 47 | > :right_anger_bubble: Before you use this package you should already have an understanding of Artisan Commands. You can read about them [here](https://laravel.com/docs/8.x/artisan). 48 | 49 | ### A Basic Command 50 | 51 | To use the Toolkit you simply need to add the `UsesConsoleToolkit` trait inside your command. 52 | 53 | Then add the `Thettler\LaravelConsoleToolkit\Attributes\ArtisanCommand` to the class to specify the name and other 54 | things like description, help, and so on. 55 | 56 | The `ArtisanCommand` requires the `name` parameter to be set. This will be the name of the Command which you can use to 57 | call it from the commandline. 58 | 59 | ```php 60 | Traditional Syntax 86 |

87 | 88 | ```php 89 | 90 | 106 | 107 | 108 | ### Descriptions, Help and Hidden Commands 109 | 110 | If you want to add a description, a help comment or mark the command as hidden, you can specify this on 111 | the `ArtisanCommand` Attribute like this: 112 | 113 | ```php 114 | #[ArtisanCommand( 115 | name: 'basic', 116 | description: 'Some useful description.', 117 | help: 'Some helpful text.', 118 | hidden: true 119 | )] 120 | class BasicCommand extends Command 121 | { 122 | use UsesConsoleToolkit; 123 | 124 | ... 125 | } 126 | ``` 127 | 128 | > I like to use named arguments for a more readable look. 129 | 130 |

Traditional Syntax 131 |

132 | 133 | ```php 134 | ... 135 | class BasicCommand extends Command 136 | { 137 | protected $signature = 'basic'; 138 | 139 | protected $description = 'Some useful description.'; 140 | 141 | protected $help = 'Some helpful text.'; 142 | 143 | protected $hidden = true; 144 | ... 145 | } 146 | ``` 147 | 148 |

149 |
150 | 151 | ### Defining Input Expectations 152 | 153 | The basic workflow to add an argument or option is always to add a property and decorate it with an Attribute. 154 | `#[Option]` if you want an option and `#[Argument]` if you want an argument. The property will be hydrated with the 155 | value from the command line, so you can use it like any normal property inside your `handle()` method. 156 | 157 | More about that in the following sections. :arrow_down: 158 | 159 | > :exclamation: The property will only be hydrated inside the `handle()` method. Keep that in mind. 160 | 161 | ### Arguments 162 | 163 | To define Arguments you create a property and add the `Argument` attribute to it. The property will be hydrated with the 164 | value from the command line, so you can use it like any normal property inside your `handle()` method. 165 | 166 | ```php 167 | ... 168 | use \Thettler\LaravelConsoleToolkit\Attributes\Argument; 169 | 170 | #[ArtisanCommand( 171 | name: 'basic', 172 | )] 173 | class BasicCommand extends Command 174 | { 175 | use UsesConsoleToolkit; 176 | 177 | #[Argument] 178 | protected string $myArgument; 179 | 180 | public function handle() { 181 | $this->line($this->myArgument); 182 | } 183 | } 184 | ``` 185 | 186 | call it like: 187 | 188 | ```bash 189 | php artisan basic myValue 190 | # Output: 191 | # myValue 192 | ``` 193 | 194 |
Traditional Syntax 195 |

196 | 197 | ```php 198 | class BasicCommand extends Command 199 | { 200 | protected $signature = 'basic {myArgument}'; 201 | 202 | public function handle() { 203 | $this->line($this->argument('myArgument')); 204 | } 205 | } 206 | ``` 207 | 208 |

209 |
210 | 211 | #### Array Arguments 212 | 213 | You can also use arrays in arguments, simply typehint the property as `array`. 214 | 215 | ```php 216 | #[ArtisanCommand( 217 | name: 'basic', 218 | )] 219 | class BasicCommand extends Command 220 | { 221 | use UsesConsoleToolkit; 222 | 223 | #[Argument] 224 | protected array $myArray; 225 | 226 | public function handle() { 227 | $this->line(implode(', ', $this->myArray)); 228 | } 229 | } 230 | ``` 231 | 232 | Call it like: 233 | 234 | ```bash 235 | php artisan basic Item1 Item2 Item3 236 | # Output 237 | # Item1, Item2, Item3 238 | ``` 239 | 240 |
Traditional Syntax 241 |

242 | 243 | ```php 244 | class BasicCommand extends Command 245 | { 246 | protected $signature = 'basic {myArgument*}'; 247 | 248 | public function handle() { 249 | $this->line($this->argument('myArgument')); 250 | } 251 | } 252 | ``` 253 | 254 |

255 |
256 | 257 | #### Optional Arguments 258 | 259 | Of course, you can use optional arguments as well. To achieve this you simply make the property nullable. 260 | 261 | > :information_source: This works with `array` as well but the property won't be null but an empty array 262 | > instead 263 | 264 | ```php 265 | #[ArtisanCommand( 266 | name: 'basic', 267 | )] 268 | class BasicCommand extends Command 269 | { 270 | use UsesConsoleToolkit; 271 | 272 | #[Argument] 273 | protected ?string $myArgument; 274 | 275 | ... 276 | } 277 | ``` 278 | 279 |
Traditional Syntax 280 |

281 | 282 | ```php 283 | class BasicCommand extends Command 284 | { 285 | protected $signature = 'basic {myArgument?}'; 286 | 287 | ... 288 | } 289 | ``` 290 | 291 |

292 |
293 | 294 | If your argument should have a default value, you can assign a value to the property which will be used as default 295 | value. 296 | 297 | ```php 298 | #[ArtisanCommand( 299 | name: 'basic', 300 | )] 301 | class BasicCommand extends Command 302 | { 303 | use UsesConsoleToolkit; 304 | 305 | #[Argument] 306 | protected string $myArgument = 'default'; 307 | 308 | ... 309 | } 310 | ``` 311 | 312 |
Traditional Syntax 313 |

314 | 315 | ```php 316 | class BasicCommand extends Command 317 | { 318 | protected $signature = 'basic {myArgument=default}'; 319 | 320 | ... 321 | } 322 | ``` 323 | 324 |

325 |
326 | 327 | #### Argument Description 328 | 329 | You can set a description for arguments as parameter on the `Argument` Attribute. 330 | 331 | ```php 332 | #[ArtisanCommand( 333 | name: 'basic', 334 | )] 335 | class BasicCommand extends Command 336 | { 337 | use UsesConsoleToolkit; 338 | 339 | #[Argument( 340 | description: 'Argument Description' 341 | )] 342 | protected string $myArgument; 343 | 344 | ... 345 | } 346 | ``` 347 | 348 |
Traditional Syntax 349 |

350 | 351 | ```php 352 | ... 353 | class BasicCommand extends Command 354 | { 355 | protected $signature = 'basic {myArgument: Argument Description}'; 356 | 357 | ... 358 | } 359 | ``` 360 | 361 |

362 |
363 | 364 | > :exclamation: :exclamation: If you have more than one argument the order inside the class will also be the order on the commandline 365 | 366 | ### Options 367 | 368 | To use options in your commands you use the `Options` Attribute. If you have set a typehint of `boolean` it will be 369 | false if the option was not set and true if it was set. 370 | 371 | ```php 372 | use \Thettler\LaravelConsoleToolkit\Attributes\Option; 373 | 374 | #[ArtisanCommand( 375 | name: 'basic', 376 | )] 377 | class BasicCommand extends Command 378 | { 379 | use UsesConsoleToolkit; 380 | 381 | #[Option] 382 | protected bool $myOption; 383 | 384 | public function handle() { 385 | dump($this->myOption); 386 | } 387 | } 388 | ``` 389 | 390 | Call it like: 391 | 392 | ```bash 393 | php artisan basic --myOption 394 | # Output 395 | # true 396 | ``` 397 | 398 | ```bash 399 | php artisan basic 400 | # Output 401 | # false 402 | ``` 403 | 404 |
Traditional Syntax 405 |

406 | 407 | ```php 408 | class BasicCommand extends Command 409 | { 410 | protected $signature = 'basic {--myOption}'; 411 | 412 | public function handle() { 413 | dump($this->option('myOption')); 414 | } 415 | } 416 | ``` 417 | 418 |

419 |
420 | 421 | #### Value Options 422 | 423 | You can add a value to an option if you type hint the property with something different as `bool`. This will 424 | automatically make it to an option with a value. If your typehint is not nullable the option will have a required value. 425 | This means the option can only be used with a value. 426 | 427 | :x: Wont work `--myoption` :white_check_mark: works `--myoption=myvalue` 428 | 429 | If you want to make the value optional simply make the type nullable or assign a value to the property 430 | 431 | ```php 432 | #[ArtisanCommand( 433 | name: 'basic', 434 | )] 435 | class BasicCommand extends Command 436 | { 437 | use UsesConsoleToolkit; 438 | 439 | #[Option] 440 | protected string $requiredValue; // if the option is used the User must specify a value 441 | 442 | #[Option] 443 | protected ?string $optionalValue; // The value is optional 444 | 445 | #[Option] 446 | protected string $defaultValue = 'default'; // The option has a default value 447 | 448 | #[Option] 449 | protected array $array; // an Array Option 450 | 451 | #[Option] 452 | protected array $defaultArray = ['default1', 'default2']; // an Array Option with default 453 | ... 454 | } 455 | ``` 456 | 457 | Call it like: 458 | 459 | ```bash 460 | php artisan basic --requiredValue=someValue --optionalValue --array=Item1 --array=Item2 461 | ``` 462 | 463 |
Traditional Syntax 464 |

465 | 466 | ```php 467 | class BasicCommand extends Command 468 | { 469 | // requiredValue is not possible 470 | // defaultArray is not possible 471 | protected $signature = 'basic {--optionalValue=} {--defaultValue=default} {--array=*}'; 472 | 473 | ... 474 | } 475 | ``` 476 | 477 |

478 |
479 | 480 | #### Option Description 481 | 482 | You can set a description for an option on the `Option` Attribute. 483 | 484 | ```php 485 | #[ArtisanCommand( 486 | name: 'basic', 487 | )] 488 | class BasicCommand extends Command 489 | { 490 | use UsesConsoleToolkit; 491 | 492 | #[Option( 493 | description: 'Option Description' 494 | )] 495 | protected bool $option; 496 | ... 497 | } 498 | ``` 499 | 500 |
Traditional Syntax 501 |

502 | 503 | ```php 504 | class BasicCommand extends Command 505 | { 506 | protected $signature = 'basic {--option: Option Description}'; 507 | } 508 | ``` 509 | 510 |

511 |
512 | 513 | #### Option Shortcuts 514 | 515 | You can set a shortcut for an option on the `Option` Attribute. 516 | 517 | > :warning: Be aware that a shortcut can only be one char long 518 | 519 | ```php 520 | #[ArtisanCommand( 521 | name: 'basic', 522 | )] 523 | class BasicCommand extends Command 524 | { 525 | use UsesConsoleToolkit; 526 | 527 | #[Option( 528 | shortcut: 'Q' 529 | )] 530 | protected bool $option; 531 | ... 532 | } 533 | ``` 534 | 535 | Call it like: 536 | 537 | ```bash 538 | php artisan basic -Q 539 | ``` 540 | 541 |
Traditional Syntax 542 |

543 | 544 | ```php 545 | class BasicCommand extends Command 546 | { 547 | protected $signature = 'basic {--Q|option}'; 548 | } 549 | ``` 550 | 551 |

552 |
553 | 554 | #### Negatable Options 555 | 556 | You can make option negatable by adding the negatable parameter to the `Option` Attribute. Now the option accepts either 557 | the flag (e.g. --yell) or its negation (e.g. --no-yell). 558 | 559 | ```php 560 | #[ArtisanCommand( 561 | name: 'basic', 562 | )] 563 | class BasicCommand extends Command 564 | { 565 | use UsesConsoleToolkit; 566 | 567 | #[Option( 568 | negatable: true 569 | )] 570 | protected bool $yell; 571 | 572 | public function handle(){ 573 | dump($this->yell); // true if called with --yell 574 | dump($this->yell); // false if called with --no-yell 575 | } 576 | } 577 | ``` 578 | 579 | Call it like: 580 | 581 | ```bash 582 | php artisan basic --yell 583 | php artisan basic --no-yell 584 | ``` 585 | 586 | #### Enum Types 587 | 588 | It is also possible to type `Arguments` or `Options` as Enum. The Package will automatically cast the input from the 589 | commandline to the typed Enum. If you use BackedEnums you use the value of the case and if you have a non backed Enum 590 | you use the name of the case. 591 | 592 | ```php 593 | enum Enum 594 | { 595 | case A; 596 | case B; 597 | case C; 598 | } 599 | 600 | enum IntEnum: int 601 | { 602 | case A = 1; 603 | case B = 2; 604 | case C = 3; 605 | } 606 | 607 | enum StringEnum: string 608 | { 609 | case A = 'String A'; 610 | case B = 'String B'; 611 | case C = 'String C'; 612 | } 613 | ``` 614 | 615 | ```php 616 | #[Argument] 617 | protected Enum $argEnum; 618 | 619 | #[Argument] 620 | protected StringEnum $argStringEnum; 621 | 622 | #[Argument] 623 | protected IntEnum $argIntEnum; 624 | 625 | #[Option] 626 | protected Enum $enum; 627 | 628 | #[Option] 629 | protected StringEnum $stringEnum; 630 | 631 | #[Option] 632 | protected IntEnum $intEnum; 633 | ``` 634 | 635 | ```bash 636 | php artisan enum B "String B" 2 --enum=B --stringEnum="String B" --intEnum=2 637 | ``` 638 | 639 | ### Input alias 640 | 641 | By default, the input name used on the commandline will be same as the property name. You can change this with the `as` 642 | parameter on the `Option` or `Argument` Attribute. This can be handy if you have conflicting property names or want a 643 | more expressive api for your commands. 644 | 645 | > :warning: If you use the `->option()` syntax you need to specify the alias name to get the option. 646 | 647 | ```php 648 | #[ArtisanCommand( 649 | name: 'basic', 650 | )] 651 | class BasicCommand extends Command 652 | { 653 | use UsesConsoleToolkit; 654 | 655 | #[Argument( 656 | as: 'alternativeArgument' 657 | )] 658 | protected string $myArgument; 659 | 660 | #[Option( 661 | as: 'alternativeName' 662 | )] 663 | protected bool $myOption; 664 | 665 | public function handle(){ 666 | dump($this->myArgument); 667 | dump($this->myOption); 668 | } 669 | } 670 | ``` 671 | 672 | Call it like: 673 | 674 | ```bash 675 | php artisan basic something --alternativeName 676 | ``` 677 | 678 | ### Special Default values 679 | 680 | If you want to use some objects with casts as default values you can use the `configureDefauls()` method on the command 681 | to set default values. 682 | 683 | ```php 684 | #[ArtisanCommand( 685 | name: 'basic', 686 | )] 687 | class BasicCommand extends Command 688 | { 689 | use UsesConsoleToolkit; 690 | 691 | #[Argument] 692 | protected BandModel $band; 693 | 694 | public function configureDefaults(): void { 695 | $this->band = BandModel::find('2'); 696 | } 697 | 698 | public function handle(){ 699 | dump($this->band); // The Band with id 2 700 | } 701 | } 702 | ``` 703 | 704 | ### Casts 705 | 706 | Cast can be specified on `Arguments` and `Options`. You can either provide a class-string of a caster to use or an 707 | instance of the caster. This is helpful to configure the caster via the constructor. 708 | 709 | #### Model Cast 710 | 711 | The Toolkit provides a cast for eloquent models out of the box. So if you typehint an eloquent model toolkit will try to 712 | match the console input to the primary key of the model and fetches it from the database. 713 | 714 | ```php 715 | #[Argument] 716 | protected BandModel $band; 717 | 718 | public function handle(){ 719 | $this->band // Well be an instance of BandModel 720 | } 721 | ``` 722 | 723 | If you want to change the column that will be used to match the input to the database, load relations or only select 724 | specific columns you can use the manual cast like this: 725 | 726 | ```php 727 | #[Argument( 728 | cast: new \Thettler\LaravelConsoleToolkit\Casts\ModelCaster( 729 | findBy: 'name', 730 | select: ['id', 'name'] 731 | with: ['songs'] 732 | ) 733 | )] 734 | protected BandModel $band; 735 | 736 | public function handle(){ 737 | $this->band // Will be an instance of BandModel 738 | } 739 | ``` 740 | 741 | #### Enum Cast 742 | 743 | The enum cast will automatically cast every typed enum to this enum. But you can also manually specify it like so. 744 | 745 | ```php 746 | #[Argument( 747 | cast: \Thettler\LaravelConsoleToolkit\Casts\EnumCaster::class 748 | )] 749 | protected Enum $argEnum; 750 | 751 | #[Option( 752 | cast: new \Thettler\LaravelConsoleToolkit\Casts\EnumCaster(Enum::class) 753 | )] 754 | protected Enum $enum; 755 | ``` 756 | 757 | #### Array Cast 758 | 759 | If you have an array and want to cast all its values to a specific type you can use the ArrayCaster. It expects a caster 760 | and a specific type: 761 | 762 | ```php 763 | #[Argument( 764 | cast: new \Thettler\LaravelConsoleToolkit\Casts\ArrayCaster( 765 | caster: \Thettler\LaravelConsoleToolkit\Casts\EnumCaster::class, 766 | type: StringEnum::class 767 | ) 768 | )] 769 | protected array $enumArray; 770 | 771 | #[Option( 772 | cast: new \Thettler\LaravelConsoleToolkit\Casts\ArrayCaster( 773 | caster: \Thettler\LaravelConsoleToolkit\Casts\EnumCaster::class, 774 | type: StringEnum::class 775 | ) 776 | )] 777 | protected array $enumArray2; 778 | ``` 779 | 780 | #### Custom Casts 781 | 782 | It's also possible to define your own casts. To do so you need to create a class that implements the `Caster` Interface. 783 | 784 | Let's have a look at small UserCast that allows to simply use the id of a user model on the command line and 785 | automatically fetch the correct user from the database: 786 | 787 | ```php 788 | getKey(); 805 | } 806 | 807 | throw new Exception(self::class . ' can only be used with type '. Band::class) 808 | } 809 | 810 | /** 811 | * This method deals with the conversion from console input to property value 812 | * 813 | * @param mixed $value The Value from the command line 814 | * @param class-string $type The type is a string representation of the type of the property 815 | * @param \ReflectionProperty $property The property reflection itself for more control 816 | * @return mixed 817 | */ 818 | public function to(mixed $value, string $type, \ReflectionProperty $property) 819 | { 820 | return $type::find($value); 821 | } 822 | } 823 | ``` 824 | 825 | Now you can use this cast ether locally on an attribute or register it globally for automatic casting like 826 | this in your AppServiceProvider 827 | 828 | ```php 829 | /** Uses the UserCaster everytime the User class is typehint on an Argument or Option */ 830 | \Thettler\LaravelConsoleToolkit\ConsoleToolkit::addCast(UserCaster::class, User::class); 831 | 832 | /** Uses the UserCaster everytime the User or MasterUser class is typehint on an Argument or Option */ 833 | \Thettler\LaravelConsoleToolkit\ConsoleToolkit::addCast(UserCaster::class, [User::class, MasterUser::class]); 834 | 835 | /** Uses the UserCaster everytime the callable returns true */ 836 | \Thettler\LaravelConsoleToolkit\ConsoleToolkit::addCast( 837 | UserCaster::class, 838 | fn (mixed $value, ReflectionProperty $property): bool => is_subclass_of($property->getType()->getName(), User::class); 839 | ); 840 | ``` 841 | 842 | ### Validation 843 | You can also use the normal laravel validation rules to validate the input. 844 | ```php 845 | #[Argument( 846 | validation: ['max:5'] 847 | )] 848 | protected string $validated; 849 | ``` 850 | 851 | If you want custom messages you need to use the Validation object 852 | ```php 853 | #[Argument( 854 | validation: new \Thettler\LaravelConsoleToolkit\Transfers\Validation( 855 | rules: ['max:5'] 856 | messages: [ 857 | 'max' => 'This is way to much!' 858 | ] 859 | ) 860 | )] 861 | protected string $validated; 862 | ``` 863 | 864 | ### Auto Ask 865 | By default, Auto Ask is enabled. Every time a command is called with an input that fails validation or is required but not 866 | specified the command automatically asks the user to enter a (new) value. If the type is an enum it will give the user 867 | choice with all the enum values. 868 | 869 | If you want to disable this behavior you can do it locally: 870 | 871 | ```php 872 | #[Argument( 873 | autoAsk: false 874 | )] 875 | protected string $dontAsk; 876 | ``` 877 | or globally in your AppServiceProvider: 878 | 879 | ```php 880 | \Thettler\LaravelConsoleToolkit\ConsoleToolkit::enableAutoAsk(false); 881 | ``` 882 | 883 | ## :robot: Testing 884 | 885 | ```bash 886 | composer test 887 | ``` 888 | 889 | ## :open_book: Changelog 890 | 891 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 892 | 893 | ## :angel: Contributing 894 | 895 | Please see [CONTRIBUTING](.github/CONTRIBUTING.md) for details. 896 | 897 | ## :lock: Security Vulnerabilities 898 | 899 | Please review [our security policy](../../security/policy) on how to report security vulnerabilities. 900 | 901 | ## :copyright: Credits 902 | 903 | - [Tobias Hettler](https://github.com/thettler) 904 | - [All Contributors](../../contributors) 905 | 906 | ## :books: License 907 | 908 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 909 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "thettler/laravel-console-toolkit", 3 | "description": "This Package provides some usefully console features like the attribute syntax for arguments and options, validation, auto ask and casting.", 4 | "keywords": [ 5 | "commands", 6 | "laravel", 7 | "attributes" 8 | ], 9 | "homepage": "https://github.com/thettler/laravel-console-toolkit", 10 | "license": "MIT", 11 | "authors": [ 12 | { 13 | "name": "Tobias Hettler", 14 | "email": "tobias.hettler@bitbench.dev", 15 | "role": "Developer" 16 | } 17 | ], 18 | "require": { 19 | "php": "^8.1", 20 | "symfony/console": "^6.0", 21 | "spatie/laravel-package-tools": "^1.9.2", 22 | "illuminate/contracts": "^9.0|^10.0" 23 | }, 24 | "require-dev": { 25 | "laravel/sail": "^1.13", 26 | "nunomaduro/collision": "^v6.1.0", 27 | "nunomaduro/larastan": "^2.0", 28 | "orchestra/testbench": "^7.0|^8.0", 29 | "pestphp/pest": "^1.21", 30 | "pestphp/pest-plugin-laravel": "^1.4", 31 | "phpstan/extension-installer": "^1.1", 32 | "phpstan/phpstan-deprecation-rules": "^1.0", 33 | "phpstan/phpstan-phpunit": "^1.0", 34 | "phpunit/phpunit": "^9.5", 35 | "spatie/laravel-ray": "^1.26" 36 | }, 37 | "autoload": { 38 | "psr-4": { 39 | "Thettler\\LaravelConsoleToolkit\\": "src" 40 | } 41 | }, 42 | "autoload-dev": { 43 | "psr-4": { 44 | "Thettler\\LaravelConsoleToolkit\\Tests\\": "tests" 45 | } 46 | }, 47 | "scripts": { 48 | "analyse": "vendor/bin/phpstan analyse", 49 | "test": "vendor/bin/pest", 50 | "test-coverage": "vendor/bin/pest coverage" 51 | }, 52 | "config": { 53 | "sort-packages": true, 54 | "allow-plugins": { 55 | "phpstan/extension-installer": true, 56 | "pestphp/pest-plugin": true 57 | } 58 | }, 59 | "extra": { 60 | "laravel": { 61 | "providers": [ 62 | "Thettler\\LaravelConsoleToolkit\\LaravelConsoleToolkitServiceProvider" 63 | ] 64 | } 65 | }, 66 | "minimum-stability": "dev", 67 | "prefer-stable": true 68 | } 69 | -------------------------------------------------------------------------------- /src/Attributes/Argument.php: -------------------------------------------------------------------------------- 1 | |Caster|null $cast 16 | * @param string|array|null|Validation $validation 17 | */ 18 | public function __construct( 19 | protected string $description = '', 20 | protected ?string $as = null, 21 | protected null|string|Caster $cast = null, 22 | protected null|string|array|Validation $validation = null, 23 | protected ?bool $autoAsk = null, 24 | ) { 25 | } 26 | 27 | public function getDescription(): string 28 | { 29 | return $this->description; 30 | } 31 | 32 | public function getAlias(): ?string 33 | { 34 | return $this->as; 35 | } 36 | 37 | public function getCast(): null|Caster|string 38 | { 39 | return $this->cast; 40 | } 41 | 42 | public function hasAutoAsk(): ?bool 43 | { 44 | return $this->autoAsk; 45 | } 46 | 47 | public function getValidation(): null|array|string|Validation 48 | { 49 | return $this->validation; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Attributes/ArtisanCommand.php: -------------------------------------------------------------------------------- 1 | |Caster|null $cast 16 | * @param string|null $shortcut 17 | * @param bool $negatable 18 | */ 19 | public function __construct( 20 | protected string $description = '', 21 | protected ?string $as = null, 22 | protected null|Caster|string $cast = null, 23 | protected ?string $shortcut = null, 24 | protected bool $negatable = false, 25 | protected null|string|array|Validation $validation = null, 26 | protected ?bool $autoAsk = null, 27 | ) { 28 | } 29 | 30 | public function getDescription(): string 31 | { 32 | return $this->description; 33 | } 34 | 35 | public function getAlias(): ?string 36 | { 37 | return $this->as; 38 | } 39 | 40 | public function getShortcut(): ?string 41 | { 42 | return $this->shortcut; 43 | } 44 | 45 | public function isNegatable(): bool 46 | { 47 | return $this->negatable; 48 | } 49 | 50 | public function getCast(): null|Caster|string 51 | { 52 | return $this->cast; 53 | } 54 | 55 | public function hasAutoAsk(): ?bool 56 | { 57 | return $this->autoAsk; 58 | } 59 | 60 | public function getValidation(): null|array|string|Validation 61 | { 62 | return $this->validation; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Casts/ArrayCaster.php: -------------------------------------------------------------------------------- 1 | $caster 12 | */ 13 | public function __construct( 14 | protected Caster|string $caster, 15 | protected string $type, 16 | ) { 17 | } 18 | 19 | /** 20 | * @param int|float|array|string|bool|null $value 21 | * @param string $type 22 | * @param \ReflectionProperty $property 23 | * @return int|float|array|string|bool|null 24 | */ 25 | public function from(mixed $value, string $type, \ReflectionProperty $property): int|float|array|string|bool|null 26 | { 27 | $value = Arr::wrap($value); 28 | 29 | return collect($value) 30 | ->map(function (mixed $item) use ($property) { 31 | return $this->getItemCaster()->from($item, $this->type, $property); 32 | }) 33 | ->all(); 34 | } 35 | 36 | public function to(mixed $value, string $type, \ReflectionProperty $property) 37 | { 38 | $value = Arr::wrap($value); 39 | 40 | return collect($value) 41 | ->map(fn ($item) => $this->getItemCaster()->to($item, $this->type, $property)) 42 | ->all(); 43 | } 44 | 45 | protected function getItemCaster(): Caster 46 | { 47 | if (is_string($this->caster)) { 48 | return app()->make($this->caster); 49 | } 50 | 51 | return $this->caster; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Casts/EnumCaster.php: -------------------------------------------------------------------------------- 1 | isBacked() 20 | ? $value->value 21 | : $value->name; 22 | } 23 | 24 | public function to(mixed $value, string $type, \ReflectionProperty $property) 25 | { 26 | $enumName = $this->getEnumName($type); 27 | 28 | if (! $enumName) { 29 | return $value; 30 | } 31 | 32 | if (! enum_exists($enumName)) { 33 | return $value; 34 | } 35 | 36 | $enum = new \ReflectionEnum($enumName); 37 | 38 | return $enum->isBacked() 39 | ? ($enumName)::from((string) $value) 40 | : $enum->getCase((string) $value)->getValue(); 41 | } 42 | 43 | protected function getEnumName(string $type): ?string 44 | { 45 | if ($this->enum) { 46 | return $this->enum; 47 | } 48 | 49 | return $type; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Casts/ModelCaster.php: -------------------------------------------------------------------------------- 1 | findBy ? $value->{$this->findBy} : $value->getKey(); 20 | } 21 | 22 | /** 23 | * @param mixed $value 24 | * @param class-string $type 25 | * @param \ReflectionProperty $property 26 | * @return mixed 27 | */ 28 | public function to(mixed $value, string $type, \ReflectionProperty $property) 29 | { 30 | return $type::where($this->findBy ?? (new $type())->getKeyName(), '=', $value) 31 | ->with($this->with) 32 | ->first($this->select); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Concerns/UsesConsoleToolkit.php: -------------------------------------------------------------------------------- 1 | configureDefaults(); 27 | parent::__construct(); 28 | } 29 | 30 | public function specifyParameters() 31 | { 32 | $this->reflection = new CommandReflection($this); 33 | 34 | if ($this->reflection->usesCommandAttribute()) { 35 | SymfonyCommand::__construct($this->name = $this->reflection->getName()); 36 | $this->setDescription($this->reflection->getDescription()); 37 | $this->setHelp($this->reflection->getHelp()); 38 | $this->setHidden($this->reflection->isHidden()); 39 | $this->setAliases($this->reflection->getAliases()); 40 | } 41 | 42 | parent::specifyParameters(); 43 | } 44 | 45 | public function configureDefaults(): void 46 | { 47 | } 48 | 49 | /** 50 | * Get the console command arguments. 51 | * 52 | * @return array 53 | */ 54 | protected function getArguments() 55 | { 56 | if (! $this->reflection->usesInputAttributes()) { 57 | return []; 58 | } 59 | 60 | return $this->reflection 61 | ->getArguments() 62 | ->map(fn (ArgumentReflection $argumentReflection) => $this->propertyToArgument($argumentReflection)) 63 | ->all(); 64 | } 65 | 66 | /** 67 | * Get the console command options. 68 | * 69 | * @return array 70 | */ 71 | protected function getOptions() 72 | { 73 | if (! $this->reflection->usesInputAttributes()) { 74 | return []; 75 | } 76 | 77 | return $this->reflection 78 | ->getOptions() 79 | ->map(fn (OptionReflection $optionReflection) => $this->propertyToOption($optionReflection)) 80 | ->all(); 81 | } 82 | 83 | protected function execute(InputInterface $input, OutputInterface $output) 84 | { 85 | $hasErrors = ! $this->errorHandling(fn () => $this->hydrateArguments()); 86 | $hasErrors = ! $this->errorHandling(fn () => $this->hydrateOptions()) || $hasErrors; 87 | 88 | if ($hasErrors) { 89 | return SymfonyCommand::FAILURE; 90 | } 91 | 92 | return parent::execute($input, $output); 93 | } 94 | 95 | protected function errorHandling(callable $callable): bool 96 | { 97 | try { 98 | $callable(); 99 | 100 | return true; 101 | } catch (ValidationException $validationException) { 102 | $rerun = false; 103 | foreach ($validationException->validator->errors()->toArray() as $key => $errors) { 104 | $inputErrorData = $validationException->inputs[$key]; 105 | 106 | $this->renderDivider($inputErrorData); 107 | $this->renderValidationErrors($inputErrorData, $errors); 108 | 109 | if ($inputErrorData->hasAutoAsk) { 110 | $answer = ! empty($inputErrorData->choices) 111 | ? $this->renderAutoAskChoice($inputErrorData) 112 | : $this->renderAutoAsk($inputErrorData); 113 | 114 | match ($inputErrorData->reflection::inputType()) { 115 | ConsoleInputType::Argument => $this->input->setArgument($key, $answer), 116 | ConsoleInputType::Option => $this->input->setOption($key, $answer), 117 | }; 118 | 119 | $rerun = true; 120 | 121 | continue; 122 | } 123 | 124 | if (! empty($inputErrorData->choices)) { 125 | $this->renderChoiceOptions($inputErrorData); 126 | } 127 | } 128 | 129 | if ($rerun) { 130 | return $this->errorHandling($callable); 131 | } 132 | 133 | return false; 134 | } 135 | } 136 | 137 | protected function hydrateArguments(): void 138 | { 139 | $this->reflection 140 | ->getArguments() 141 | ->pipeThrough( 142 | fn (Collection $collection) => $this->validate($collection), 143 | ) 144 | ->each(function (ArgumentReflection $argumentReflection) { 145 | $this->{$argumentReflection->getName()} = $argumentReflection->castTo( 146 | $this->argument($argumentReflection->getAlias() ?? $argumentReflection->getName()) 147 | ); 148 | }); 149 | } 150 | 151 | protected function hydrateOptions(): void 152 | { 153 | $this->reflection 154 | ->getOptions() 155 | ->pipeThrough(fn (Collection $collection) => $this->validate($collection)) 156 | ->each(function (OptionReflection $optionReflection) { 157 | $consoleName = $optionReflection->getAlias() ?? $optionReflection->getName(); 158 | if (! $optionReflection->hasRequiredValue()) { 159 | $this->{$optionReflection->getName()} = $optionReflection->castTo($this->option($consoleName)); 160 | 161 | return; 162 | } 163 | 164 | if ($this->option($consoleName) === null) { 165 | return; 166 | } 167 | 168 | $this->{$optionReflection->getName()} = $optionReflection->castTo($this->option($consoleName)); 169 | }); 170 | } 171 | 172 | protected function propertyToArgument(ArgumentReflection $argument): InputArgument 173 | { 174 | return match (true) { 175 | $argument->isArray() && ! $argument->isOptional() => $this->makeInputArgument( 176 | $argument, 177 | $argument->isAutoAskEnabled() ? InputArgument::IS_ARRAY | InputArgument::OPTIONAL : InputArgument::IS_ARRAY | InputArgument::REQUIRED 178 | ), 179 | 180 | $argument->isArray() => $this->makeInputArgument( 181 | $argument, 182 | InputArgument::IS_ARRAY, 183 | $argument->getDefaultValue() 184 | ), 185 | 186 | $argument->isOptional() || $argument->getDefaultValue() => $this->makeInputArgument( 187 | $argument, 188 | InputArgument::OPTIONAL, 189 | $argument->getDefaultValue() 190 | ), 191 | 192 | default => $this->makeInputArgument( 193 | $argument, 194 | $argument->isAutoAskEnabled() ? InputArgument::OPTIONAL : InputArgument::REQUIRED 195 | ), 196 | }; 197 | } 198 | 199 | protected function propertyToOption(OptionReflection $option): InputOption 200 | { 201 | return match (true) { 202 | $option->hasValue() && $option->isArray() => $this->makeInputOption( 203 | $option, 204 | InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 205 | $option->getDefaultValue() 206 | ), 207 | 208 | $option->hasRequiredValue() => $this->makeInputOption( 209 | $option, 210 | $option->isAutoAskEnabled() ? InputOption::VALUE_OPTIONAL : InputOption::VALUE_REQUIRED, 211 | ), 212 | 213 | $option->hasValue() => $this->makeInputOption( 214 | $option, 215 | InputOption::VALUE_OPTIONAL, 216 | $option->getDefaultValue() 217 | ), 218 | 219 | $option->isNegatable() => $this->makeInputOption( 220 | $option, 221 | InputOption::VALUE_NEGATABLE, 222 | $option->getDefaultValue() !== null ? $option->getDefaultValue() : false 223 | ), 224 | 225 | default => $this->makeInputOption($option, InputOption::VALUE_NONE), 226 | }; 227 | } 228 | 229 | protected function makeInputArgument( 230 | ArgumentReflection $argument, 231 | ?int $mode, 232 | string|bool|int|float|array|null $default = null 233 | ): InputArgument { 234 | return new InputArgument( 235 | $argument->getAlias() ?? $argument->getName(), 236 | $mode, 237 | $argument->getDescription(), 238 | $default 239 | ); 240 | } 241 | 242 | protected function makeInputOption( 243 | OptionReflection $option, 244 | ?int $mode, 245 | string|bool|int|float|array|null $default = null 246 | ): InputOption { 247 | return new InputOption( 248 | $option->getAlias() ?? $option->getName(), 249 | $option->getShortcut(), 250 | $mode, 251 | $option->getDescription(), 252 | $default 253 | ); 254 | } 255 | 256 | protected function renderChoiceOptions(InputErrorData $inputErrorData): void 257 | { 258 | $this->info("Possible values for: {$inputErrorData->key}."); 259 | 260 | foreach ($inputErrorData->choices as $choice) { 261 | $this->warn(" - {$choice}"); 262 | } 263 | } 264 | 265 | protected function renderAutoAsk(InputErrorData $inputErrorData): mixed 266 | { 267 | return $this->ask('Please enter "'.$inputErrorData->key.'"'); 268 | } 269 | 270 | protected function renderAutoAskChoice(InputErrorData $inputErrorData): string|array 271 | { 272 | return $this->choice( 273 | 'Please enter "'.$inputErrorData->key.'"', 274 | $inputErrorData->choices, 275 | $inputErrorData->reflection->getDefaultValue(), 276 | null, 277 | $inputErrorData->reflection->isArray() 278 | ); 279 | } 280 | 281 | protected function renderValidationErrors(InputErrorData $inputErrorData, array $errors): void 282 | { 283 | foreach ($errors as $error) { 284 | $this->error($error); 285 | } 286 | } 287 | 288 | protected function renderDivider(InputErrorData $inputErrorData): void 289 | { 290 | $this->line(" "); 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /src/Concerns/UsesInputValidation.php: -------------------------------------------------------------------------------- 1 | $collection 16 | * @return Collection 17 | * @throws ValidationException 18 | */ 19 | protected function validate(Collection $collection): Collection 20 | { 21 | [ 22 | 'values' => $values, 23 | 'rules' => $rules, 24 | 'messages' => $messages, 25 | 'choices' => $choices 26 | ] = $this->extractValidationData($collection); 27 | 28 | if (empty($rules)) { 29 | return $collection; 30 | } 31 | 32 | $validator = Validator::make( 33 | $values, 34 | $rules, 35 | $messages 36 | ); 37 | 38 | if (! $validator->fails()) { 39 | return $collection; 40 | } 41 | 42 | $inputErrors = $collection->mapWithKeys(fn (InputReflection $reflection) => [ 43 | $reflection->getName() => new InputErrorData( 44 | key: $reflection->getName(), 45 | choices: $choices[$reflection->getName()] ?? [], 46 | reflection: $reflection, 47 | hasAutoAsk: $this->hasAutoAskEnabled($reflection), 48 | ), 49 | ])->all(); 50 | 51 | throw new ValidationException($validator, $inputErrors); 52 | } 53 | 54 | /** 55 | * @return array{values:mixed, rules:null|string|array, messages: array} 56 | */ 57 | protected function extractValidationData(Collection $collection): array 58 | { 59 | return $collection->reduce(fn (array $carry, InputReflection $reflection) => [ 60 | 'values' => [...$carry['values'], ...$this->extractInputValues($reflection)], 61 | 'rules' => [...$carry['rules'], ...$this->extractInputRules($reflection)], 62 | 'messages' => [...$carry['messages'], ...$this->extractValidationMessages($reflection)], 63 | 'choices' => [...$carry['choices'], ...$this->extractInputChoices($reflection)], 64 | ], [ 65 | 'values' => [], 66 | 'rules' => [], 67 | 'messages' => [], 68 | 'choices' => [], 69 | ]); 70 | } 71 | 72 | protected function extractValidationMessages(InputReflection $reflection): array 73 | { 74 | if (! $reflection->getValidationMessage()) { 75 | return []; 76 | } 77 | 78 | return collect($reflection->getValidationMessage()) 79 | ->mapWithKeys(fn (string $value, string $key) => ["{$reflection->getName()}.{$key}" => $value]) 80 | ->all(); 81 | } 82 | 83 | protected function extractInputValues( 84 | InputReflection $reflection 85 | ): array { 86 | $inputName = $reflection->getAlias() ?? $reflection->getName(); 87 | 88 | return [ 89 | $reflection->getName() => match ($reflection::inputType()) { 90 | ConsoleInputType::Argument => $this->argument($inputName), 91 | ConsoleInputType::Option => $this->option($inputName), 92 | }, 93 | ]; 94 | } 95 | 96 | protected function extractInputRules( 97 | InputReflection $reflection 98 | ): array { 99 | $rules = []; 100 | 101 | if ($this->hasAutoAskEnabled($reflection) && ! $reflection->isArray()) { 102 | $rules[] = 'required'; 103 | } 104 | 105 | if (empty($reflection->getValidationRules())) { 106 | return empty($rules) ? [] : [$reflection->getName() => $rules]; 107 | } 108 | 109 | return [$reflection->getName() => [...$rules, ...$reflection->getValidationRules()]]; 110 | } 111 | 112 | protected function extractInputChoices( 113 | InputReflection $reflection 114 | ): array { 115 | if (empty($reflection->getChoices())) { 116 | return []; 117 | } 118 | 119 | return [$reflection->getName() => $reflection->getChoices()]; 120 | } 121 | 122 | /** 123 | * @param InputReflection $reflection 124 | * @return bool 125 | */ 126 | protected function hasAutoAskEnabled(InputReflection $reflection): bool 127 | { 128 | return match ($reflection::inputType()) { 129 | ConsoleInputType::Argument => $reflection->isAutoAskEnabled(), 130 | ConsoleInputType::Option => $reflection->isAutoAskEnabled() 131 | && $reflection->hasRequiredValue() 132 | && array_key_exists($reflection->getName(), $this->getSpecifiedOptions()), 133 | }; 134 | } 135 | 136 | protected function getSpecifiedOptions(): array 137 | { 138 | return (new \ReflectionClass($this->input))->getProperty('options')->getValue($this->input); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/ConsoleToolkit.php: -------------------------------------------------------------------------------- 1 | 9 | * @phpstan-type CasterConfigValue class-string | callable(mixed, \ReflectionProperty): bool | array 10 | */ 11 | class ConsoleToolkit 12 | { 13 | /** @var array */ 14 | public static array $casts = []; 15 | 16 | public static bool $hasAutoAskEnabled = false; 17 | 18 | /** 19 | * @param CasterConfigKey $caster 20 | * @param CasterConfigValue $matches 21 | * @return void 22 | */ 23 | public static function addCast(string $caster, array|string|callable $matches): void 24 | { 25 | static::$casts[$caster] = $matches; 26 | } 27 | 28 | /** 29 | * @param array $caster 30 | * @return void 31 | */ 32 | public static function setCast(array $caster): void 33 | { 34 | static::$casts = $caster; 35 | } 36 | 37 | public static function enableAutoAsk(bool $enable = true): void 38 | { 39 | static::$hasAutoAskEnabled = $enable; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Contracts/Caster.php: -------------------------------------------------------------------------------- 1 | name('laravel-console-toolkit'); 17 | 18 | ConsoleToolkit::enableAutoAsk(); 19 | 20 | ConsoleToolkit::addCast( 21 | EnumCaster::class, 22 | function (mixed $value, ReflectionProperty $property): bool { 23 | if (! $property->getType() instanceof \ReflectionNamedType) { 24 | return false; 25 | } 26 | 27 | return enum_exists($property->getType()->getName()); 28 | } 29 | ); 30 | 31 | ConsoleToolkit::addCast( 32 | ModelCaster::class, 33 | function (mixed $value, ReflectionProperty $property): bool { 34 | if (! $property->getType() instanceof \ReflectionNamedType) { 35 | return false; 36 | } 37 | 38 | return is_subclass_of($property->getType()->getName(), Model::class); 39 | } 40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Reflections/ArgumentReflection.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class ArgumentReflection extends InputReflection 12 | { 13 | public static function isArgument(\ReflectionProperty $property): bool 14 | { 15 | return ! empty($property->getAttributes(Argument::class)); 16 | } 17 | 18 | public static function inputType(): ConsoleInputType 19 | { 20 | return ConsoleInputType::Argument; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Reflections/CommandReflection.php: -------------------------------------------------------------------------------- 1 | */ 14 | public \ReflectionClass $reflection; 15 | public ?ArtisanCommand $attribute; 16 | 17 | public function __construct( 18 | public Command $command 19 | ) { 20 | $this->reflection = new \ReflectionClass($this->command); 21 | $this->attribute = $this->initCommandAttribute(); 22 | } 23 | 24 | public function usesAttributeSyntax(): bool 25 | { 26 | return $this->usesCommandAttribute() || $this->usesInputAttributes(); 27 | } 28 | 29 | public function usesCommandAttribute(): bool 30 | { 31 | return $this->attribute !== null; 32 | } 33 | 34 | public function usesInputAttributes(): bool 35 | { 36 | return $this->getArguments()->isNotEmpty() || $this->getOptions()->isNotEmpty(); 37 | } 38 | 39 | /** 40 | * @return Collection 41 | */ 42 | public function getArguments(): Collection 43 | { 44 | return collect($this->reflection->getProperties()) 45 | ->filter(fn (\ReflectionProperty $property) => ArgumentReflection::isArgument($property)) 46 | ->map( 47 | fn (\ReflectionProperty $property) => new ArgumentReflection( 48 | $property, 49 | $property->getAttributes(Argument::class)[0]->newInstance(), 50 | $this->command, 51 | ) 52 | ); 53 | } 54 | 55 | /** 56 | * @return Collection 57 | */ 58 | public function getOptions(): Collection 59 | { 60 | return collect($this->reflection->getProperties()) 61 | ->filter(fn (\ReflectionProperty $property) => OptionReflection::isOption($property)) 62 | ->map( 63 | fn (\ReflectionProperty $property) => new OptionReflection( 64 | $property, 65 | $property->getAttributes(Option::class)[0]->newInstance(), 66 | $this->command, 67 | ) 68 | ); 69 | } 70 | 71 | public function getName(): ?string 72 | { 73 | if (! $this->attribute) { 74 | return $this->command->getName(); 75 | } 76 | 77 | return $this->attribute->name; 78 | } 79 | 80 | public function getDescription(): string 81 | { 82 | if (! $this->attribute) { 83 | return $this->command->getDescription(); 84 | } 85 | 86 | return $this->attribute->description; 87 | } 88 | 89 | public function getHelp(): string 90 | { 91 | if (! $this->attribute) { 92 | return $this->command->getHelp(); 93 | } 94 | 95 | return $this->attribute->help; 96 | } 97 | 98 | public function isHidden(): bool 99 | { 100 | if (! $this->attribute) { 101 | return $this->command->isHidden(); 102 | } 103 | 104 | return $this->attribute->hidden; 105 | } 106 | 107 | public function getAliases(): array 108 | { 109 | if (! $this->attribute) { 110 | return []; 111 | } 112 | 113 | return $this->attribute->aliases; 114 | } 115 | 116 | protected function initCommandAttribute(): ?ArtisanCommand 117 | { 118 | $attributes = $this->reflection->getAttributes(ArtisanCommand::class); 119 | 120 | if (empty($attributes)) { 121 | return null; 122 | } 123 | 124 | return $attributes[0]->newInstance(); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/Reflections/InputReflection.php: -------------------------------------------------------------------------------- 1 | property->getType())) { 34 | throw new InvalidTypeException("A type is required for the console input \"{$this->property->getName()}\"."); 35 | } 36 | 37 | if (! $type instanceof \ReflectionNamedType) { 38 | throw new InvalidTypeException("Only named types can be used for the console input \"{$this->property->getName()}\"."); 39 | } 40 | 41 | $this->type = $type; 42 | } 43 | 44 | abstract public static function inputType(): ConsoleInputType; 45 | 46 | public function getName(): string 47 | { 48 | return $this->property->getName(); 49 | } 50 | 51 | public function getAlias(): ?string 52 | { 53 | return $this->consoleInput->getAlias(); 54 | } 55 | 56 | public function getValidationRules(): array 57 | { 58 | $autoRules = []; 59 | 60 | if (enum_exists($this->type)) { 61 | $autoRules[] = new Enum($this->type); 62 | } 63 | 64 | $rules = $this->consoleInput->getValidation() instanceof Validation 65 | ? $this->consoleInput->getValidation()->rules 66 | : $this->consoleInput->getValidation(); 67 | 68 | return [...$autoRules, ...Arr::wrap($rules)]; 69 | } 70 | 71 | public function getValidationMessage(): null|array 72 | { 73 | return $this->consoleInput->getValidation() instanceof Validation 74 | ? $this->consoleInput->getValidation()->messages 75 | : null; 76 | } 77 | 78 | public function getChoices(): array 79 | { 80 | if (! enum_exists($this->type)) { 81 | return []; 82 | } 83 | 84 | return array_map( 85 | fn (\UnitEnum|\BackedEnum $enum) => $enum instanceof \BackedEnum ? $enum->value : $enum->name, 86 | $this->type::cases() 87 | ); 88 | } 89 | 90 | public function getDescription(): string 91 | { 92 | return $this->consoleInput->getDescription(); 93 | } 94 | 95 | public function getDefaultValue(): string|bool|int|float|array|null 96 | { 97 | return $this->property->hasDefaultValue() || $this->property->isInitialized($this->command) 98 | ? $this->castFrom() 99 | : null; 100 | } 101 | 102 | public function isOptional(): bool 103 | { 104 | return $this->property->hasDefaultValue() 105 | || $this->property->getType()?->allowsNull() 106 | || $this->property->isInitialized($this->command); 107 | } 108 | 109 | public function isAutoAskEnabled(): bool 110 | { 111 | if ($this->consoleInput->hasAutoAsk() !== null) { 112 | return $this->consoleInput->hasAutoAsk(); 113 | } 114 | 115 | return ConsoleToolkit::$hasAutoAskEnabled; 116 | } 117 | 118 | public function isArray(): bool 119 | { 120 | if (($type = $this->property->getType()) instanceof \ReflectionNamedType) { 121 | return $type->getName() === 'array'; 122 | } 123 | 124 | return false; 125 | } 126 | 127 | public function castFrom(): int|float|array|string|bool|null 128 | { 129 | $value = $this->property->isInitialized($this->command) 130 | ? $this->property->getValue($this->command) 131 | : $this->property->getDefaultValue(); 132 | 133 | $caster = $this->getCaster($value, $this->property); 134 | 135 | if (! $caster) { 136 | return $value; 137 | } 138 | 139 | return $caster->from($value, $this->type, $this->property); 140 | } 141 | 142 | public function castTo(int|array|float|string|bool|null $value): mixed 143 | { 144 | $caster = $this->getCaster($value, $this->property); 145 | 146 | if (! $caster) { 147 | return $value; 148 | } 149 | 150 | return $caster->to($value, $this->type, $this->property); 151 | } 152 | 153 | protected function getCaster(mixed $value, \ReflectionProperty $property): ?Caster 154 | { 155 | if ($cast = $this->consoleInput->getCast()) { 156 | return is_string($cast) ? app()->make($cast) : $cast; 157 | } 158 | 159 | $casterString = collect(ConsoleToolkit::$casts) 160 | ->filter(function (callable|string|array $matcher) use ($value, $property) { 161 | if (is_callable($matcher)) { 162 | return $matcher($value, $property); 163 | } 164 | 165 | if (is_string($matcher)) { 166 | return $matcher === $this->type; 167 | } 168 | 169 | if (! is_array($matcher)) { 170 | return false; 171 | } 172 | 173 | foreach ($matcher as $match) { 174 | if ($match !== $this->type) { 175 | continue; 176 | } 177 | 178 | return true; 179 | } 180 | 181 | return false; 182 | }) 183 | ->keys() 184 | ->first(); 185 | 186 | 187 | if (! $casterString) { 188 | return null; 189 | } 190 | 191 | return app()->make($casterString); 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/Reflections/OptionReflection.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class OptionReflection extends InputReflection 13 | { 14 | public function __construct(\ReflectionProperty $property, Option $consoleInput, Command $commandReflection) 15 | { 16 | parent::__construct($property, $consoleInput, $commandReflection); 17 | } 18 | 19 | public static function isOption(\ReflectionProperty $property): bool 20 | { 21 | return ! empty($property->getAttributes(Option::class)); 22 | } 23 | 24 | public function isNegatable(): bool 25 | { 26 | return $this->consoleInput->isNegatable(); 27 | } 28 | 29 | public function hasRequiredValue(): bool 30 | { 31 | return $this->hasValue() && ! $this->isOptional(); 32 | } 33 | 34 | public function getShortcut(): ?string 35 | { 36 | return $this->consoleInput->getShortcut(); 37 | } 38 | 39 | public function hasValue(): bool 40 | { 41 | if (($type = $this->property->getType()) instanceof \ReflectionNamedType) { 42 | return $type->getName() !== 'bool'; 43 | } 44 | 45 | return false; 46 | } 47 | 48 | public function isAutoAskEnabled(): bool 49 | { 50 | return $this->hasRequiredValue() && parent::isAutoAskEnabled(); 51 | } 52 | 53 | public static function inputType(): ConsoleInputType 54 | { 55 | return ConsoleInputType::Option; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Rules/Enum.php: -------------------------------------------------------------------------------- 1 | type)) { 19 | return false; 20 | } 21 | 22 | try { 23 | if (method_exists($this->type, 'tryFrom')) { 24 | return ! is_null($this->type::tryFrom($value)); 25 | } 26 | 27 | return ! empty(array_filter($this->type::cases(), fn (\UnitEnum $enum) => $enum->name === $value)); 28 | } catch (TypeError $e) { 29 | return false; 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Transfers/InputErrorData.php: -------------------------------------------------------------------------------- 1 | $reflection 14 | * @param bool $hasAutoAsk 15 | */ 16 | public function __construct( 17 | public readonly string $key, 18 | public readonly array $choices, 19 | public readonly InputReflection $reflection, 20 | public readonly bool $hasAutoAsk, 21 | ) { 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Transfers/Validation.php: -------------------------------------------------------------------------------- 1 |