How can I put this? CLI apps are cool. The ability to open a terminal anywhere and just run a command to do a job that might have taken you much longer. Opening the browser going to the right page, logging in and finding what it is you need to do, and waiting for the page to load .... You get the picture.
Over the last few years, the command terminal has had a lot of investment; from ZSH to auto-completing, from FIG to Warp - CLI is something we cannot escape. I build CLI apps to help me be more efficient with small tasks or do a job on a schedule.
Whenever I look online at anything relating to Laravel, it is always a web app, and it makes sense. Laravel is a fantastic web application framework, after all! However, leveraging what we love about Laravel is also available for CLI applications. Now we can use a full install of Laravel and run the scheduler to run artisan commands where we need to - but this is overkill sometimes. If you do not need a web interface, you do not need Laravel. Instead, let us talk about Laravel Zero, another brainchild of Nuno Maduro.
Laravel Zero describes itself as a "micro-framework for console application" - which is pretty accurate. It allows you to build CLI applications using a proven framework - that is smaller than using something like Laravel. It is well documented, robust, and actively maintained - making it a perfect choice for any CLI applications you might want to build.
In this tutorial, I will walk through a somewhat simple example of using Laravel Zero with the hopes that it will show you just how useful it can be. We will build a CLI application that will enable us to see projects and tasks in my Todoist account so that I don't have to open up an application or web browser.
To get started, we need to go to the web app for Todoist and open the integration settings to get our API token. We will need this a little later. Our first step is the create a new Laravel Zero project that we can use.
1composer create-project --prefer-dist laravel-zero/laravel-zero todoist
Open this new project in your IDE so that we can start building our CLI application. The first thing we know we will want to do is store our API token, as we don't want to have to paste this in every time we want to run a new command. A typical approach here is to store the API token in the user's home directory in a config file within a hidden directory. So we will look at how we can achieve this.
We want to create a ConfigurationRepository
that will allow us to work with our local filesystem to get and set values that we might need within our CLI application. As with most code I write, I will create an interface/contract that binds the implementation in case I want to change this to work with another filesystem.
1declare(strict_types=1); 2 3namespace App\Contracts; 4 5interface ConfigurationContract 6{ 7 public function all(): array; 8 9 public function clear(): ConfigurationContract;10 11 public function get(string $key, mixed $default = null): array|int|string|null;12 13 public function set(string $key, array|int|string $value): ConfigurationContract;14}
Now we know what this should do, we can look at the implementation for our local file system:
1declare(strict_types=1); 2 3namespace App\Repositories; 4 5use App\Contracts\ConfigurationContract; 6use App\Exceptions\CouldNotCreateDirectory; 7use Illuminate\Support\Arr; 8use Illuminate\Support\Facades\File; 9 10final class LocalConfiguration implements ConfigurationContract11{12 public function __construct(13 protected readonly string $path,14 ) {}15 16 public function all(): array17 {18 if (! is_dir(dirname(path: $this->path))) {19 if (! mkdir(20 directory: $concurrentDirectory = dirname(21 path: $this->path,22 ),23 permissions: 0755,24 recursive: true25 ) && !is_dir(filename: $concurrentDirectory)) {26 throw new CouldNotCreateDirectory(27 message: "Directory [$concurrentDirectory] was not created",28 );29 }30 }31 32 if (file_exists(filename: $this->path)) {33 return json_decode(34 json: file_get_contents(35 filename: $this->path,36 ),37 associative: true,38 depth: 512,39 flags: JSON_THROW_ON_ERROR,40 );41 }42 43 return [];44 }45 46 public function clear(): ConfigurationContract47 {48 File::delete(49 paths: $this->path,50 );51 52 return $this;53 }54 55 public function get(string $key, mixed $default = null): array|int|string|null56 {57 return Arr::get(58 array: $this->all(),59 key: $key,60 default: $default,61 );62 }63 64 public function set(string $key, array|int|string $value): ConfigurationContract65 {66 $config = $this->all();67 68 Arr::set(69 array: $config,70 key: $key,71 value: $value,72 );73 74 file_put_contents(75 filename: $this->path,76 data: json_encode(77 value: $config,78 flags: JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT,79 ),80 );81 82 return $this;83 }84}
We use some of the helper methods in Laravel and some basic PHP to get content and check files - then read and write content where required. With this, we can manage a file anywhere in our local filesystem. Our next step is to bind this into our container so that we can set our current implementation and how we want to be able to resolve this from the container.
1declare(strict_types=1); 2 3namespace App\Providers; 4 5use App\Contracts\ConfigurationContract; 6use App\Repositories\LocalConfiguration; 7use Illuminate\Support\ServiceProvider; 8 9final class AppServiceProvider extends ServiceProvider10{11 public array $bindings = [12 ConfigurationContract::class => LocalConfiguration::class,13 ];14 15 public function register(): void16 {17 $this->app->singleton(18 abstract: LocalConfiguration::class,19 concrete: function (): LocalConfiguration {20 $path = isset($_ENV['APP_ENV']) && $_ENV['APP_ENV'] === 'testing'21 ? base_path(path: 'tests')22 : ($_SERVER['HOME'] ?? $_SERVER['USERPROFILE']);23 24 return new LocalConfiguration(25 path: "$path/.todo/config.json",26 );27 },28 );29 }30}
We bind our contract to our implementation using the service providers bindings
property here. Then in the register method, we set up how we want our implementation to be built. Now when we inject the ConfigurationContract
into a command, we will get an instance of LocalConfiguration
that has been resolved as a singleton.
The first thing we want to do with the Laravel Zero app now is to give it a name so that we can call the CLI application using a name that is relevant to what we are building. I am going to call mine "todo".
1php application app:rename todo
Now we can call our command using php todo ...
and start building out the CLI commands we will want to use. Before we build out commands, we will need to create a class that integrates with the Todoist API. Again, I will make an interface/contract for this if I decide to switch from Todoist to another provider.
1declare(strict_types=1); 2 3namespace App\Contracts; 4 5interface TodoContract 6{ 7 public function projects(): ResourceContract; 8 9 public function tasks(): ResourceContract;10}
We have two methods, projects
and tasks
, which will return a resource class for us to work with. And as usual, this resource class needs a contract. The Resource Contract will use a Data Object Contract, but instead of creating this, I will use one I built into a package of mine:
1composer require juststeveking/laravel-data-object-tools
Now we can create the Resource Contract itself:
1declare(strict_types=1); 2 3namespace App\Contracts; 4 5use Illuminate\Support\Collection; 6use JustSteveKing\DataObjects\Contracts\DataObjectContract; 7 8interface ResourceContract 9{10 public function list(): Collection;11 12 public function get(string $identifier): DataObjectContract;13 14 public function create(DataObjectContract $resource): DataObjectContract;15 16 public function update(string $identifier, DataObjectContract $payload): DataObjectContract;17 18 public function delete(string $identifier): bool;19}
These are basic CRUD options on the resource itself, named helpfully. Of course, we can extend this in the implementation should we want a more accessible API. Now let us get started on building out our Todoist implementation.
1declare(strict_types=1); 2 3namespace App\Services\Todoist; 4 5use App\Contracts\ResourceContract; 6use App\Contracts\TodoContract; 7use App\Services\Todoist\Resources\ProjectResource; 8use App\Services\Todoist\Resources\TaskResource; 9 10final class TodoistClient implements TodoContract11{12 public function __construct(13 public readonly string $url,14 public readonly string $token,15 ) {}16 17 public function projects(): ResourceContract18 {19 return new ProjectResource(20 client: $this,21 );22 }23 24 public function tasks(): ResourceContract25 {26 return new TaskResource(27 client: $this,28 );29 }30}
I will publish this project on GitHub to allow you to see the complete working example.
Our TodoistClient
will return a new instance of the ProjectResource
passing in an instance of our client to the constructor so that we can access the URL and token, which is why these properties are protected and not private.
Let's look at what our ProjectResource
will look like. Then we can walk through how it is going to work.
1declare(strict_types=1); 2 3namespace App\Services\Todoist\Resources; 4 5use App\Contracts\ResourceContract; 6use App\Contracts\TodoContract; 7use Illuminate\Support\Collection; 8use JustSteveKing\DataObjects\Contracts\DataObjectContract; 9 10final class ProjectResource implements ResourceContract11{12 public function __construct(13 private readonly TodoContract $client,14 ) {}15 16 public function list(): Collection17 {18 // TODO: Implement list() method.19 }20 21 public function get(string $identifier): DataObjectContract22 {23 // TODO: Implement get() method.24 }25 26 public function create(DataObjectContract $resource): DataObjectContract27 {28 // TODO: Implement create() method.29 }30 31 public function update(string $identifier, DataObjectContract $payload): DataObjectContract32 {33 // TODO: Implement update() method.34 }35 36 public function delete(string $identifier): bool37 {38 // TODO: Implement delete() method.39 }40}
Quite a simple structure that follows our interface/contract quite well. Now we can start looking at how we want to build up requests and send them. I like to do this, and feel free to do this differently, is to create a Trait that my Resource uses to send
requests. I can then set this new send
method on the ResourceContract
so that resources either use the trait or have to implement their own send method. The Todoist API has several resources, so sharing this behavior makes more sense within a trait. Let's look at this trait:
1declare(strict_types=1); 2 3namespace App\Services\Concerns; 4 5use App\Exceptions\TodoApiException; 6use App\Services\Enums\Method; 7use Illuminate\Http\Client\PendingRequest; 8use Illuminate\Http\Client\Response; 9use Illuminate\Support\Facades\Http;10 11trait SendsRequests12{13 public function send(14 Method $method,15 string $uri,16 null|array $data = null,17 ): Response {18 $request = $this->makeRequest();19 20 $response = $request->send(21 method: $method->value,22 url: $uri,23 options: $data ? ['json' => $data] : [],24 );25 26 if ($response->failed()) {27 throw new TodoApiException(28 response: $response,29 );30 }31 32 return $response;33 }34 35 protected function makeRequest(): PendingRequest36 {37 return Http::baseUrl(38 url: $this->client->url,39 )->timeout(40 seconds: 15,41 )->withToken(42 token: $this->client->token,43 )->withUserAgent(44 userAgent: 'todo-cli',45 );46 }47}
We have two methods, one which will build the request and one which will send the request - as we want a standard way to do both. Let us now add the send
method onto the ResourceContract
to enforce this approach across providers.
1declare(strict_types=1); 2 3namespace App\Contracts; 4 5use App\Services\Enums\Method; 6use Illuminate\Http\Client\Response; 7use Illuminate\Support\Collection; 8use JustSteveKing\DataObjects\Contracts\DataObjectContract; 9 10interface ResourceContract11{12 public function list(): Collection;13 14 public function get(string $identifier): DataObjectContract;15 16 public function create(DataObjectContract $resource): DataObjectContract;17 18 public function update(string $identifier, DataObjectContract $payload): DataObjectContract;19 20 public function delete(string $identifier): bool;21 22 public function send(23 Method $method,24 string $uri,25 null|array $data = null,26 ): Response;27}
Now our Resources either have to create their own way of creating and sending a request, or they can implement this trait. As you can see from the code examples, I have created a helper Enum for the request method - this code is in the repository, so feel free to dive into the code there for more information.
Before we get too far with the integration side, it is probably time we created a command to log in with. After all, this tutorial is about Laravel Zero!
Create a new command using the following in your terminal:
1php todo make:command Todo/LoginCommand
This command will need to take the API token and store it in the configuration repository for future commands. Let's look at how this command works:
1declare(strict_types=1); 2 3namespace App\Commands\Todo; 4 5use App\Contracts\ConfigurationContract; 6use LaravelZero\Framework\Commands\Command; 7 8final class LoginCommand extends Command 9{10 protected $signature = 'login';11 12 protected $description = 'Store your API credentials for the Todoist API.';13 14 public function handle(ConfigurationContract $config): int15 {16 $token = $this->secret(17 question: 'What is your Todoist API token?',18 );19 20 if (! $token) {21 $this->warn(22 string: "You need to supply an API token to use this application.",23 );24 25 return LoginCommand::FAILURE;26 }27 28 $config->clear()->set(29 key: 'token',30 value: $token,31 )->set(32 key: 'url',33 value: 'https://api.todoist.com/rest/v1',34 );35 36 $this->info(37 string: 'We have successfully stored your API token for Todoist.',38 );39 40 return LoginCommand::SUCCESS;41 }42}
We inject the ConfigurationContract
into the handle method, which will resolve the configuration for us. Then we ask for an API token as a secret so that it isn't displayed on the user's terminal as they type. After clearing any current values, we can use the config to set the new values for the token and the URL.
Once we can authenticate, we can create an additional command to list our projects. Let's create this now:
1php todo make:command Todo/Projects/ListCommand
This command will need to use the TodoistClient
to fetch all projects and list them in a table. Let's have a look at what this looks like.
1declare(strict_types=1); 2 3namespace App\Commands\Todo\Projects; 4 5use App\Contracts\TodoContract; 6use App\DataObjects\Project; 7use LaravelZero\Framework\Commands\Command; 8use Throwable; 9 10final class ListCommand extends Command11{12 protected $signature = 'projects:list';13 14 protected $description = 'List out Projects from the Todoist API.';15 16 public function handle(17 TodoContract $client,18 ): int {19 try {20 $projects = $client->projects()->list();21 } catch (Throwable $exception) {22 $this->warn(23 string: $exception->getMessage(),24 );25 26 return ListCommand::FAILURE;27 }28 29 $this->table(30 headers: ['ID', 'Project Name', 'Comments Count', 'Shared', 'URL'],31 rows: $projects->map(fn (Project $project): array => $project->toArray())->toArray(),32 );33 34 return ListCommand::SUCCESS;35 }36}
If you look at the code in the repository on GitHub, you will see that the list
command on the ProjectResource
returns a collection of Project
data objects. This allows us to map each item in the collection, cast the object to an array, and return the collection as an array, so we can easily see what projects we have in tabular format. Using the right terminal, we can also click on the URL for the project to open this within a browser if we need to.
As you can see from the above approach, it is pretty simple to build a CLI application using Laravel Zero - the only limitation to what you could build is your imagination.
As mentioned throughout this tutorial, you can find the GitHub Repository online here, so you can clone the complete working example.