Introduction
As Laravel web developers, we often need to build Artisan commands for our applications. But interacting with the console can sometimes feel a little cumbersome. Laravel Prompts is a package that aims to improve this experience by providing a simple approach to user-friendly forms in the console.
In this article, we'll take a look at Laravel Prompts and some of its features that you can use. We'll then build a simple GitHub CLI client using Prompts to demonstrate how to use it in your applications. Finally, we'll show how to write tests for your Prompts commands.
By the end of the article, you should understand how to use Prompts in your applications and how to test them.
What is Laravel Prompts?
Laravel Prompts is a first-party package built by the Laravel team. It provides a simple way to add forms with browser-like functionality to your Artisan terminal commands. For example, it provides the ability to define input placeholders, validation rules, default values, loading spinners, and more.
If you're new to Artisan commands in Laravel, you might want to check out my article on Processes and Artisan commands in Laravel, which covers what they are, how to use them, and how to test them.
Basic usage of Prompts
Laravel Prompts provides many console features that you can use in your Artisan commands. Let's explore some of the most common ones.
You can check out the official documentation for a complete list of features.
Text input
Prompts provides a text
helper that you can use to create a text input field in your forms.
1use function Laravel\Prompts\text;2 3$name = text('What is your favorite programming language?');
That little bit of code produces the following terminal UI:
If the user enters PHP
(as shown in the screenshot above), the $name
variable will contain the string PHP
. If they were to leave it blank, the $name
variable would be an empty string.
The text
helper also provides a few extra options that you can use to customize the input field:
1use function Laravel\Prompts\text;2 3$name = text(4 label: 'What is your programming language?',5 default: 'PHP',6 required: true,7 validate: ['max:20'],8);
In the code example above, we're setting a default value for the textbox of PHP
, making it a required field, and setting a validation rule that the input must be a maximum of 20 characters long. If the user tries to enter more than 20 characters or leaves the input blank, they'll see an error message and can try again:
Similarly, we can also add a placeholder to the input field:
1use function Laravel\Prompts\text;2 3$name = text(4 label: 'What is your favorite programming language?',5 placeholder: 'e.g. PHP, JavaScript, Ruby',6);
Which generates the following prompt:
Password input
Prompts also provides a password
helper that allows a user to input text without it being displayed in the console. This is useful when you want to hide sensitive information like passwords, API tokens, etc.
You can use it in a similar way to the text
helper:
1use function Laravel\Prompts\password;2 3$password = password(4 label: 'Enter your GitHub API token:',5 placeholder: 'token-goes-here',6 hint: 'Your personal access token',7);
Which results in a password input:
Confirm input
There's a handy confirm
helper that you can use to ask the user to confirm something, which is useful when you want the user to explicitly confirm they're happy to perform a potentially destructive action (such as deleting data):
1use function Laravel\Prompts\confirm;2 3$confirmed = confirm('Do you want to continue?');
Which produces a confirmation:
You can then use the result of the input like so:
1use function Laravel\Prompts\confirm;2 3$confirmed = confirm('Do you want to continue?');4 5if (!$confirmed) {6 // "No" was selected.7}8 9// Continue as normal.
The confirm
helper also provides a few extra options that you can use to customize it:
1$confirmed = confirm(2 label: 'Do you want to continue?',3 default: false,4 yes: 'Yes, delete it!',5 no: 'No, cancel it!',6 hint: 'The GitHub repository will be deleted.'7);
These options produce an input that's a bit easier to understand:
Select input
You can use the handy select
helper to get input from the user from a list of options. It is also helpful if you need to display a menu—we'll use this one later in the tutorial.
Let's take a look at how you can use the select
helper:
1use function Laravel\Prompts\select; 2 3$role = select( 4 label: 'What is your favorite programming language?', 5 options: [ 6 'php' => 'PHP', 7 'js' => 'JavaScript', 8 'ruby' => 'Ruby', 9 ],10);
This code creates a multi-choice menu:
If the user were to select the "JavaScript" option, then the $role
variable would contain the string js
.
Pause input
Another handy prompt is the pause
helper, which can pause the execution of the command and wait for the user to press a key before continuing. It's useful when you want to display some information in the console and only continue when the user is ready.
You can use the pause
helper like this:
1use function Laravel\Prompts\pause;2 3pause('Press ENTER to continue...');
To generate the following prompt:
Forms
My favorite feature of Prompts is the ability to tie the different prompts together to create a form. It makes it easy to gather information from the user in a structured way using chained method calls rather than using the Prompts individually.
You can do this using the form
helper:
1$form = form() 2 ->text( 3 label: 'Repo name:', 4 required: true, 5 validate: ['max:100'], 6 name: 'repo_name' 7 ) 8 ->confirm( 9 label: 'Private repo?',10 name: 'is_private'11 )12 ->submit();
This would result in the following form:
In the simple example above, we're defining a form that contains a text input field and a confirmation field. We then use the submit
method to display the form to the user and gather the input.
If the user were to input a repo name of Hello-World
and select that the repo should be private, then the $form
variable would be an array like so:
1[2 'repo_name' => 'Hello-World',3 'is_private' => true,4];
Building a simple GitHub CLI client with Prompts
Now that we know what Prompts can do, let's tie it together by building a simple Artisan command.
We'll build a basic command to interact with the GitHub API to:
- List the repositories belonging to the given user.
- Create a new repository.
By the end of this section, you'll have a simple command like this:
Limitations
Before we get stuck into any code, I need to mention the limitations of this example.
I've chosen to interact with the GitHub API because you're probably already familiar with GitHub. Focusing on the Prompts functionality and code will be easier than on the API itself.
Since we don't want to get bogged down in the details of the GitHub API, we're going to ignore a few things:
- The GitHub API endpoint for listing the user's repositories is paginated. We're going to ignore this and return the first page. In a real-world application, you'd want to handle pagination.
- We won't be adding any error handling related to the API. You'd want to handle this in a real-world application to give the user a better experience.
Now that we've got that out of the way—let's get started!
Setting the configuration
First, you must create a personal access token to interact with the GitHub API. Once you've created it, you can add it to your .env
file like so:
.env
1GITHUB_TOKEN=your-token-goes-here
So that we can access this token in our application, we'll add a github.token
field to our config/services.php
file like so:
config/services.php
1return [23 // ...45 'github' => [6 'token' => env('GITHUB_TOKEN'),7 ],89];
This means we can now access the token using config('services.github.token')
.
Creating and binding the interface
Now, let's prepare the code for our GitHub API client that the command will use. We'll create a new App\Interfaces\GitHub\GitHub
interface to define the methods we need to interact with the GitHub API.
We're using an interface here so that we can swap out the implementation of the GitHub API client later—this makes testing much easier because we can swap out our implementation for a test double that doesn't make any API requests.
The interface will define two methods:
-
listRepos
- This method returns a collection of repositories from GitHub belonging to the user. -
createRepo
- This method creates a new repository on GitHub.
The interface may look something like this:
app/Interfaces/GitHub/GitHub.php
1namespace App\Interfaces\GitHub;23use App\Collections\GitHub\RepoCollection;4use App\DataTransferObjects\GitHub\NewRepoData;5use App\DataTransferObjects\GitHub\Repo;67interface GitHub8{9 public function listRepos(): RepoCollection;1011 public function createRepo(NewRepoData $repoData): Repo;12}
You may have noticed that we're also mentioning three classes that we've not created yet: App\Collections\GitHub\RepoCollection
, App\DataTransferObjects\GitHub\NewRepoData
, and App\DataTransferObjects\GitHub\Repo
. We'll create these classes in the next section.
Next, we want to create a binding for the interface in Laravel's service container. This will allow Laravel to resolve an instance of our intended implementation when we use dependency injection to request an interface instance. Don't worry if this doesn't make sense—it'll make more sense when we create the command.
To create the binding, we can use the $this->app->bind
method in the register
method of our App\Providers\AppServiceProvider
class. We've defined that whenever we attempt to resolve an instance of App\Interfaces\GitHub\GitHub
, Laravel should return an instance of App\Services\GitHub\GitHubService
(we'll create this class later in the article). We'll also pass the GitHub token from the configuration (that we set earlier) to the constructor of the App\Services\GitHub\GitHubService
:
app/Providers/AppServiceProvider.php
1namespace App\Providers;23use App\Interfaces\GitHub\GitHub;4use App\Services\GitHub\GitHubService;5use Illuminate\Support\ServiceProvider;67class AppServiceProvider extends ServiceProvider8{9 public function register(): void10 {11 $this->app->bind(12 abstract: GitHub::class,13 concrete: fn (): GitHubService => new GitHubService(14 token: config('services.github.token'),15 )16 );17 }18}
Creating the collection and data transfer objects
Now, let's create the three classes that we mentioned earlier:
-
App\DataTransferObjects\GitHub\Repo
- This will hold the data for a single repository. -
App\DataTransferObjects\GitHub\NewRepoData
- This will hold the data needed to create a new repository on GitHub. -
App\Collections\GitHub\RepoCollection
- This is an instance of Laravel'sIlluminate\Support\Collection
that will hold multipleApp\DataTransferObjects\GitHub\Repo
objects that we retrieve from the GitHub API.
The App\DataTransferObjects\GitHub\Repo
may look like:
app/DataTransferObjects/GitHub/Repo.php
1declare(strict_types=1);23namespace App\DataTransferObjects\GitHub;45use Carbon\CarbonInterface;67final readonly class Repo8{9 public function __construct(10 public int $id,11 public string $owner,12 public string $name,13 public bool $private,14 public string $description,15 public CarbonInterface $createdAt,16 ) {17 //18 }19}
The App\DataTransferObjects\GitHub\NewRepoData
may look like this:
app/DataTransferObjects/GitHub/NewRepoData.php
1declare(strict_types=1);23namespace App\DataTransferObjects\GitHub;45final readonly class NewRepoData6{7 public function __construct(8 public string $name,9 public bool $private,10 ) {11 //12 }13}
And here's the App\Collections\GitHub\RepoCollection
:
app/Collections/GitHub/RepoCollection.php
1declare(strict_types=1);23namespace App\Collections\GitHub;45use App\DataTransferObjects\GitHub\Repo;6use Illuminate\Support\Collection;78/** @extends Collection<int,Repo> */9final class RepoCollection extends Collection10{11 //12}
Creating the API client
Let's create the App\Services\GitHub\GitHubService
class that implements the App\Interfaces\GitHub\GitHub
interface we created earlier. This class will interact with the GitHub API and return the data in our desired format.
Let's take a look at what the class might look like, and then we'll break down what's happening:
app/Services/GitHub/GitHubService.php
1declare(strict_types=1);23namespace App\Services\GitHub;45use App\Collections\GitHub\RepoCollection;6use App\DataTransferObjects\GitHub\NewRepoData;7use App\DataTransferObjects\GitHub\Repo;8use App\Interfaces\GitHub\GitHub;9use Carbon\CarbonImmutable;10use Illuminate\Http\Client\PendingRequest;11use Illuminate\Support\Facades\Http;1213final class GitHubService implements GitHub14{15 private const BASE_URL = 'https://api.github.com';1617 public function __construct(18 public readonly string $token19 ) {20 //21 }2223 public function listRepos(): RepoCollection24 {25 $repos = $this->client()26 ->get(url: '/user/repos')27 ->collect()28 ->map(fn (array $repo): Repo => $this->buildRepoFromResponseData($repo));2930 return RepoCollection::make($repos);31 }3233 public function createRepo(NewRepoData $repoData): Repo34 {35 $repo = $this->client()36 ->post(37 url: '/user/repos',38 data: [39 'name' => $repoData->name,40 'private' => $repoData->private,41 ]42 )43 ->json();4445 return $this->buildRepoFromResponseData($repo);46 }4748 private function buildRepoFromResponseData(array $repo): Repo49 {50 return new Repo(51 id: $repo['id'],52 owner: $repo['owner']['login'],53 name: $repo['name'],54 private: $repo['private'],55 description: $repo['description'] ?? '',56 createdAt: CarbonImmutable::create($repo['created_at']),57 );58 }5960 private function client(): PendingRequest61 {62 return Http::withToken($this->token)63 ->baseUrl(self::BASE_URL)64 ->throw();65 }66}
As we can see, we've defined a BASE_URL
constant on the class that holds the base URL for all the requests we'll make to the GitHub API. We've also used constructor property promotion to define the $token
property and assign it a value from the constructor. This is the personal access token that we stored earlier in the .env
file and passed to the service when creating our binding.
We then have two public methods that are enforced by the App\Interfaces\GitHub\GitHub
interface we created earlier.
The first is the listRepos
method that makes a GET
request to the /user/repos
endpoint on the GitHub API. It then maps over the response data and creates a new App\DataTransferObjects\GitHub\Repo
object for each repository. These are then added to an App\Collections\GitHub\RepoCollection
object and returned.
The second is the createRepo
method that makes a POST
request to the /user/repos
endpoint on the GitHub API. It sends the name and privacy status of the new repository in the request body. It then returns a new App\DataTransferObjects\GitHub\Repo
object from the response data.
In a real-life project, you'd likely want to add extra things like error handling, pagination handling, caching, etc. But for this article, we're keeping it simple to focus more on the Prompts.
Now that our API client is ready, let's create the Artisan command.
Creating the command
To create our command, we'll first run the following Artisan command:
1php artisan make:command GitHubCommand
We'll then update the command's signature and description to something meaningful:
app/Console/Commands/GitHubCommand.php
1declare(strict_types=1);23namespace App\Console\Commands;45use Illuminate\Console\Command;67final class GitHubCommand extends Command8{9 protected $signature = 'github';1011 protected $description = 'Interact with GitHub using Laravel Prompts';1213 // ...14}
This means we can now run our command with php artisan github
in our terminal.
Next, we'll update our handle
method to display a welcome message and then call a new method called displayMenu
, which will display the main menu for the command:
app/Console/Commands/GitHubCommand.php
1declare(strict_types=1);23namespace App\Console\Commands;45use App\Interfaces\GitHub\GitHub;6use Illuminate\Console\Command;78use function Laravel\Prompts\info;9use function Laravel\Prompts\select;1011final class GitHubCommand extends Command12{13 protected $signature = 'github';1415 protected $description = 'Interact with GitHub using Laravel Prompts';1617 private GitHub $gitHub;1819 public function handle(GitHub $gitHub): int20 {21 $this->gitHub = $gitHub;2223 info('Interact with GitHub using Laravel Prompts!');2425 $this->displayMenu();2627 return self::SUCCESS;28 }2930 private function displayMenu(): void31 {32 match ($this->getMenuChoice()) {33 'list' => $this->listRepositories(),34 'create' => $this->createRepository(),35 'exit' => null,36 };37 }3839 private function getMenuChoice(): string40 {41 return select(42 label: 'Menu:',43 options: [44 'list' => 'List your public GitHub repositories',45 'create' => 'Create a new GitHub repository',46 'exit' => 'Exit',47 ]);48 }49}
In the code above, we're first adding an argument to the handle
method of the command. This command is an instance of the App\Interfaces\GitHub\GitHub
interface that we created earlier. This means that when we execute the command, Laravel will automatically resolve an instance of the App\Services\GitHub\GitHubService
class that we bound to the interface in the service container earlier. We're then making it a class property so that we can access it from other methods in the command.
Following this, we're using the info
Prompts helper to display a simple welcome message to the user.
We're then using the select
Prompts helper to display a menu to the user with three options: "List your public GitHub repositories," "Create a new GitHub repository," and "Exit." The result of this selection is then used in the match
statement to determine which method to call next. If the user selects the list
option, we'll call the listRepositories
method. If they choose the create
option, we'll call the createRepository
method. If they pick the exit
option, we'll do nothing and exit the command. We haven't created the listRepositories
and createRepository
methods yet—we'll do that next.
Here's what our menu looks like:
We'll now create the listRepositories
method that will list the user's repositories:
app/Console/Commands/GitHubCommand.php
1declare(strict_types=1);23namespace App\Console\Commands;45use App\Collections\GitHub\RepoCollection;6use App\DataTransferObjects\GitHub\Repo;7use App\Interfaces\GitHub\GitHub;8use Illuminate\Console\Command;910use function Laravel\Prompts\info;11use function Laravel\Prompts\pause;12use function Laravel\Prompts\select;13use function Laravel\Prompts\spin;1415final class GitHubCommand extends Command16{17 // ...1819 private function listRepositories(): void20 {21 $repos = $this->getReposFromGitHub();2223 $selectedRepoId = select(24 label: 'Select a repository:',25 options: $repos->mapWithKeys(fn (Repo $repo): array => [$repo->id => $repo->name]),26 );2728 // Find the repo that we just selected.29 $selectedRepo = $repos->first(30 fn (Repo $repo): bool => $repo->id === $selectedRepoId31 );3233 $this->displayRepoInformation($selectedRepo);3435 $this->returnToMenu();36 }3738 private function getReposFromGitHub(): RepoCollection39 {40 return once(function () {41 return spin(42 callback: fn() => $this->gitHub->listRepos(),43 message: 'Fetching your GitHub repositories...'44 );45 });46 }4748 // ...49}
At the top of the listRepositories
method, we're starting by calling the getReposFromGitHub
method. Inside the getReposFromGitHub
method, we're calling the listRepos
method on the App\Services\GitHub\GitHubService
class we created earlier. This will return an App\Collections\GitHub\RepoCollection
containing the user's repositories for us to display.
You may have noticed we've wrapped the call to listRepos
inside the spin
and once
helper functions.
The spin
function is a Prompts helper that will display a loading spinner and a message (in this case, "Fetching your GitHub repositories...") while the request is in-flight. I love this function because it adds a bit of interactivity to the command and lets the user know that something is happening in the background. Once the request is complete, the spinner will disappear so we can display the results.
The once
function isn't a Prompts helper but is a Laravel memoization function. This means the first time we call the function, we'll call the GitHub API to get the user's repositories. The result is cached until the end of the request/command lifecycle, so we'll return the same result on subsequent calls without making the call again. This is useful because we don't want to make the same API request multiple times if we don't need to.
After we've fetched the repositories, we display them to the user using the select
Prompts helper. Using the select
helper, we can treat it like a menu so the user can select a single repository:
After they've selected a repository, we then display the repository information using the displayRepoInformation
method. Finally, we call the returnToMenu
method to return the user to the main menu.
The displayRepoInformation
method is responsible for displaying the information about the selected repository:
app/Console/Commands/GitHubCommand.php
1declare(strict_types=1);23namespace App\Console\Commands;45use App\Collections\GitHub\RepoCollection;6use App\DataTransferObjects\GitHub\NewRepoData;7use App\DataTransferObjects\GitHub\Repo;8use App\Interfaces\GitHub\GitHub;9use Illuminate\Console\Command;1011use function Laravel\Prompts\confirm;12use function Laravel\Prompts\form;13use function Laravel\Prompts\info;14use function Laravel\Prompts\pause;15use function Laravel\Prompts\select;16use function Laravel\Prompts\spin;1718final class GitHubCommand extends Command19{20 // ...2122 private function displayRepoInformation(Repo $repo): void23 {24 $this->components->twoColumnDetail('ID', (string) $repo->id);25 $this->components->twoColumnDetail('Owner', $repo->owner);26 $this->components->twoColumnDetail('Name', $repo->name);27 $this->components->twoColumnDetail('Description', $repo->description);28 $this->components->twoColumnDetail('Private', $repo->private ? '✅' : '❌');29 $this->components->twoColumnDetail('Created At', $repo->createdAt->format('Y-m-d H:i:s'));30 }3132 private function returnToMenu(): void33 {34 pause('Press ENTER to return to menu...');3536 $this->displayMenu();37 }38}
In the displayRepoInformation
method above, we're using the built-in Laravel console "two-column detail" component to display a list of details about the selected repository to the user.
Then, in the returnToMenu
method, we use the pause
Prompts helper to display a message to the user. This will pause the execution of the command and wait for the user to press the ENTER
key. Once they've pressed the key, we call the displayMenu
method (that we looked at earlier) to return them to the main menu.
This will look like:
Now that we've looked at how to list the user's repositories, let's look at how to create a new repository.
As we've already seen from our main menu, the user can select the create
option to create a new repository. When they do this, we'll call the createRepository
method:
app/Console/Commands/GitHubCommand.php
1declare(strict_types=1);23namespace App\Console\Commands;45use App\Collections\GitHub\RepoCollection;6use App\DataTransferObjects\GitHub\NewRepoData;7use App\DataTransferObjects\GitHub\Repo;8use App\Interfaces\GitHub\GitHub;9use Illuminate\Console\Command;1011use function Laravel\Prompts\confirm;12use function Laravel\Prompts\form;13use function Laravel\Prompts\info;14use function Laravel\Prompts\pause;15use function Laravel\Prompts\select;16use function Laravel\Prompts\spin;1718final class GitHubCommand extends Command19{20 // ...2122 private function createRepository(): void23 {24 $formData = $this->displayNewRepoForm();2526 if (!confirm('Are you sure you want to continue?')) {27 info('Returning to menu...');28 $this->displayMenu();2930 return;31 }3233 // Create an instance of NewRepoData with the form data.34 $repoData = new NewRepoData(35 name: $formData['repo_name'],36 private: $formData['is_private']37 );3839 // Create the repository.40 $repo = spin(41 fn (): Repo => $this->gitHub->createRepo($repoData),42 'Creating repository...',43 );4445 info('Repository created successfully!');4647 $this->displayRepoInformation($repo);4849 $this->returnToMenu();50 }5152 private function displayNewRepoForm(): array53 {54 return form()55 ->text(56 label: 'Repo name:',57 required: true,58 validate: ['max:100'],59 name: 'repo_name'60 )61 ->confirm(62 label: 'Private repo?',63 name: 'is_private'64 )65 ->submit();66 }67}
Let's break down what's happening here. We're first starting by calling the displayNewRepoForm
method. Here, we're using the form
Prompts helper to display the form to the user and gather the name and visibility of our new repository. By returning the result of the form, we then have access to the user's answers using the createRepository
method.
After gathering the form data, we're then using the confirm
Prompts helper to ask the user if they're sure they want to continue. If they select "No," we'll display a message to the user and then return them to the main menu. If they select "Yes," we'll continue creating the repository. We're doing this to reduce the chance of the user accidentally creating a repository with incorrect information:
Once we have confirmation from the user, we create an instance of App\DataTransferObjects\GitHub\NewRepoData
with the form data and pass it to the createRepo
method on the App\Services\GitHub\GitHubService
class. This will create a new repository on GitHub with the details we provided and return the repository data. You may have noticed that we're also using the spin
Prompts helper here to display a loading spinner while the repository is being created.
After creating the repository, we display the repository information to the user using the displayRepoInformation
method. Finally, we're calling the returnToMenu
method to return the user to the main menu:
The completed App\Console\Commands\GitHubCommand
class looks like this:
app/Console/Commands/GitHubCommand.php
1declare(strict_types=1);23namespace App\Console\Commands;45use App\Collections\GitHub\RepoCollection;6use App\DataTransferObjects\GitHub\NewRepoData;7use App\DataTransferObjects\GitHub\Repo;8use App\Interfaces\GitHub\GitHub;9use Illuminate\Console\Command;1011use function Laravel\Prompts\confirm;12use function Laravel\Prompts\form;13use function Laravel\Prompts\info;14use function Laravel\Prompts\pause;15use function Laravel\Prompts\select;16use function Laravel\Prompts\spin;1718final class GitHubCommand extends Command19{20 protected $signature = 'github';2122 protected $description = 'Interact with GitHub using Laravel Prompts';2324 private GitHub $gitHub;2526 public function handle(GitHub $gitHub): int27 {28 $this->gitHub = $gitHub;2930 info('Interact with GitHub using Laravel Prompts!');3132 $this->displayMenu();3334 return self::SUCCESS;35 }3637 private function displayMenu(): void38 {39 match ($this->getMenuChoice()) {40 'list' => $this->listRepositories(),41 'create' => $this->createRepository(),42 'exit' => null,43 };44 }4546 private function getMenuChoice(): string47 {48 return select(49 label: 'Menu:',50 options: [51 'list' => 'List your public GitHub repositories',52 'create' => 'Create a new GitHub repository',53 'exit' => 'Exit',54 ]);55 }5657 private function listRepositories(): void58 {59 $repos = $this->getReposFromGitHub();6061 $selectedRepoId = select(62 label: 'Select a repository:',63 options: $repos->mapWithKeys(fn (Repo $repo): array => [$repo->id => $repo->name]),64 );6566 // Find the repo that we just selected.67 $selectedRepo = $repos->first(68 fn (Repo $repo): bool => $repo->id === $selectedRepoId69 );7071 $this->displayRepoInformation($selectedRepo);7273 $this->returnToMenu();74 }7576 private function createRepository(): void77 {78 $formData = $this->displayNewRepoForm();7980 if (!confirm('Are you sure you want to continue?')) {81 info('Returning to menu...');82 $this->displayMenu();8384 return;85 }8687 // Create an instance of NewRepoData with the form data.88 $repoData = new NewRepoData(89 name: $formData['repo_name'],90 private: $formData['is_private']91 );9293 // Create the repository.94 $repo = spin(95 fn (): Repo => $this->gitHub->createRepo($repoData),96 'Creating repository...',97 );9899 info('Repository created successfully!');100101 $this->displayRepoInformation($repo);102103 $this->returnToMenu();104 }105106 private function displayNewRepoForm(): array107 {108 return form()109 ->text(110 label: 'Repo name:',111 required: true,112 validate: ['max:100'],113 name: 'repo_name'114 )115 ->confirm(116 label: 'Private repo?',117 name: 'is_private'118 )119 ->submit();120 }121122 private function getReposFromGitHub(): RepoCollection123 {124 return once(function () {125 return spin(126 callback: fn() => $this->gitHub->listRepos(),127 message: 'Fetching your GitHub repositories...'128 );129 });130 }131132 private function displayRepoInformation(Repo $repo): void133 {134 $this->components->twoColumnDetail('ID', (string) $repo->id);135 $this->components->twoColumnDetail('Owner', $repo->owner);136 $this->components->twoColumnDetail('Name', $repo->name);137 $this->components->twoColumnDetail('Description', $repo->description);138 $this->components->twoColumnDetail('Private', $repo->private ? '✅' : '❌');139 $this->components->twoColumnDetail('Created At', $repo->createdAt->format('Y-m-d H:i:s'));140 }141142 private function returnToMenu(): void143 {144 pause('Press ENTER to return to menu...');145146 $this->displayMenu();147 }148}
Testing the command
Now that we've created our Artisan command using Prompts, let's write some tests to ensure it works as expected.
Since we're focusing on Prompts in this article, we're not going to write any tests related to the actual API calls that are made within the App\Services\GitHub\GitHubService
class. You would want to write tests for that class in a real-life project to ensure it works as expected.
Instead, we'll test the command and fake the data returned from the service class. So that we can fake the interactions between the command and the GitHub API, we're going to be creating a test double. This class acts as a stand-in for the GitHub API client. We'll then swap out the real implementation for the test double in our tests. Let's take a look at how we might do this.
We'll first create a new App\Services\GitHub\GitHubServiceFake
class that implements the App\Interfaces\GitHub\GitHub
interface. By implementing the interface, we must define the listRepos
and createRepo
methods so the command can access them:
app/Services/GitHub/GitHubServiceFake.php
1declare(strict_types=1);23namespace App\Services\GitHub;45use App\Collections\GitHub\RepoCollection;6use App\DataTransferObjects\GitHub\NewRepoData;7use App\DataTransferObjects\GitHub\Repo;8use App\Interfaces\GitHub\GitHub;9use Carbon\CarbonImmutable;1011final readonly class GitHubServiceFake implements GitHub12{13 public function listRepos(): RepoCollection14 {15 return RepoCollection::make([16 new Repo(17 id: 1,18 owner: 'ash-jc-allen',19 name: 'Hello-World',20 private: true,21 description: 'This is your first repo!',22 createdAt: CarbonImmutable::create(2024, 05, 29),23 ),24 new Repo(25 id: 2,26 owner: 'ash-jc-allen',27 name: 'Hello-World-2',28 private: false,29 description: 'This is your second repo!',30 createdAt: CarbonImmutable::create(2024, 05, 29),31 ),32 ]);33 }3435 public function createRepo(NewRepoData $repoData): Repo36 {37 return new Repo(38 id: 3,39 owner: 'ash-jc-allen',40 name: $repoData->name,41 private: $repoData->private,42 description: 'New repo description',43 createdAt: now(),44 );45 }46}
In this code example above, we can see that calls to both methods will return some hardcoded data that we can test against. However, these are just simple examples. In your projects, you may want to make the results configurable so you can test different scenarios, such as handling errors.
Now that we have our test double ready, let's write two basic tests that assert:
- The user can list their repositories.
- The user can create a new repository.
We'll take a look at the test class and then discuss what's happening:
tests/Feature/Commands/GitHubCommandTest.php
1declare(strict_types=1);23namespace Tests\Feature\Commands;45use App\Interfaces\GitHub\GitHub;6use App\Services\GitHub\GitHubServiceFake;7use Laravel\Prompts\Key;8use Laravel\Prompts\Prompt;9use PHPUnit\Framework\Attributes\Test;10use Tests\TestCase;1112final class GitHubCommandTest extends TestCase13{14 protected function setUp(): void15 {16 parent::setUp();1718 $this->swap(19 abstract: GitHub::class,20 instance: new GitHubServiceFake(),21 );22 }2324 #[Test]25 public function repositories_can_be_listed(): void26 {27 // Fake the ENTER key press so we can bypass the "pause".28 Prompt::fake([Key::ENTER]);2930 $this->artisan('github')31 ->expectsOutputToContain('Interact with GitHub using Laravel Prompts!')3233 // Assert the menu is displayed. We will select "list".34 ->expectsQuestion('Menu:', 'list')35 ->expectsOutputToContain('Fetching your GitHub repositories...')3637 // Select the first repo and assert its details are displayed.38 ->expectsQuestion('Select a repository:', '1')39 ->expectsOutputToContain('1')40 ->expectsOutputToContain('ash-jc-allen')41 ->expectsOutputToContain('Hello-World')42 ->expectsOutputToContain('This is your first repo')43 ->expectsOutputToContain('✅')44 ->expectsOutputToContain('2024-05-29 00:00:00')45 ->expectsOutputToContain('Press ENTER to return to menu...')46 ->expectsQuestion('Menu:', 'exit')47 ->assertOk();48 }4950 #[Test]51 public function repo_can_be_created(): void52 {53 // Fake the ENTER key press so we can bypass the "pause".54 Prompt::fake([Key::ENTER]);5556 $this->artisan('github')57 ->expectsOutputToContain('Interact with GitHub using Laravel Prompts!')5859 // Assert the menu is displayed. We will select "create".60 ->expectsQuestion('Menu:', 'create')6162 // Input details for the new repo63 ->expectsQuestion('Repo name:', 'honeybadger')64 ->expectsQuestion('Private repo?', true)6566 // Confirm we want to create the repo67 ->expectsQuestion('Are you sure you want to continue?', true)68 ->expectsOutputToContain('Creating repository...')69 ->expectsOutputToContain('Repository created successfully!')7071 // Assert the repo details are output correctly72 ->expectsOutputToContain('3')73 ->expectsOutputToContain('ash-jc-allen')74 ->expectsOutputToContain('honeybadger')75 ->expectsOutputToContain('New repo description')76 ->expectsOutputToContain('✅')77 ->expectsQuestion('Menu:', 'exit')78 ->assertOk();79 }80}
In the setUp
method, we're creating a new instance of the App\Services\GitHub\GitHubServiceFake
class and instructing Laravel to use that instance whenever we try and resolve an instance of the App\Interfaces\GitHub\GitHub
interface from the service container. This means that when we run our tests, we won't be making any requests to the GitHub API and instead will be using the hardcoded data that we've defined in our test double class.
In the first test (repositories_can_be_listed
), we're starting by faking the ENTER
key press. If we don't do this, the command will get stuck at the pause
Prompt and won't be able to continue. By faking the key press, the command will continue as expected.
When running our Laravel tests, Prompts will fall back to using the Symfony implementations you typically use in your Artisan commands. This means we can use all the usual testing methods in our console tests, such as expectsOutputToContain
and expectsQuestion
.
We're then executing the command by using $this->artisan('github')
. This will run the command to interact with it and assert the output. By using the expectsOutputToContain
method, we can assert that the output contains the expected text. We're then using the expectsQuestion
method to simulate the user's input. The first argument passed to this method is the question being asked, and the second argument is the answer we'd like to provide. We're then checking that the repository (that we hardcoded in the App\Services\GitHub\GitHubServiceFake
class) is displayed correctly.
Similarly, in the second test (repo_can_be_created
), we're doing the same thing but creating a new repository this time. Since we're using the App\Services\GitHub\GitHubServiceFake
class, we won't be making any requests to the GitHub API, and instead, we'll be returning some hardcoded data. We're then asserting that the repository is displayed correctly.
Conclusion
In this article, we looked at Laravel Prompts and some of its features that you can use. We then built a simple GitHub CLI client using Prompts to demonstrate how to use it in your applications. Finally, we learned how to write tests for your Prompts commands.
Hopefully, you now feel confident enough to build your terminal applications with Laravel Prompts!