Build a Command Line Tool With Symfony

June 14, 2021
Written by
Joseph Udonsak
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

Build a Command Line Tool with Symfony

Once upon a time (long before the mouse was invented), the only way to talk to a computer was via command line commands. A user would type a preset command and the computer would execute the associated instruction(s) and respond to the user by displaying some text.

Of course, it was not without its disadvantages especially for users who weren't very comfortable with computers. This and other innovations that followed in the early computing days gave rise to the Graphical User Interface which has made interaction with computers more intuitive and inclusive.

The Command Line Interface (CLI) has, however, not lost its relevance. With less pressure on CPU resources, the CLI presents a powerful medium for executing tasks without hassle.

We will see this first hand in this article as we'll use the Symfony CLI to scaffold (rapidly reducing development time by automatically generating needed files and application configuration) and build a CLI tool with Symfony. After it's built, I will show you how to build on it and automate recurrent processes.

The tool we'll build will generate and email reports for an expense management application..

Prerequisites

To get the most out of this tutorial, you will need the following:

Getting started

To get started, create a new Symfony project named "expense-management-cli" by running the command below.

symfony new expense-management-cli

Next, navigate into the newly created project directory using the following command.

cd expense-management-cli

Then, we need to install the libraries which the project will depend on; these are:

  1. Doctrine: The Doctrine ORM will help with managing the application database
  2. DoctrineFixturesBundle: This will help us with loading expenses into the database
  3. Maker: This will help us with creating controllers, entities and the like
  4. Faker: We will use this to generate fake data for our application
  5. PhpSpreadsheetBundle: This bundle integrates your Symfony app with the PHPOffice PhpSpreadsheet productivity library which we will use to generate excel versions of our expense reports
  6. Sengrid: This library allows you to quickly and easily use the Twilio SendGrid Web API v3 via PHP

To install them, run the two commands below.

composer req doctrine yectep/phpspreadsheet-bundle sendgrid/sendgrid
composer req --dev maker fakerphp/faker orm-fixtures

Next, create a .env.local file from the .env file, which Symfony generated during creation of the project, by running the command below.

cp .env .env.local

Note: This file is ignored by Git as it’s automatically added to the .gitignore  file which Symfony also generated. One of the reasons for this file is the accepted best practice of storing your credentials there to keep them safe.

Next, you need to update the DATABASE_URL parameter in .env.local to use SQLite for the database instead of PostgreSQL. To do that, comment out the existing DATABASE_URL entry, and uncomment the SQLite option, which you can see below.

DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"

Note: The database will be created in ./var and be named data.db.

With those changes made, let's run the application to be sure that everything is in order. To do that, run the command below.

symfony serve -d

By default, Symfony projects run on port 8000, so navigating to https://localhost:8000/ should show the default Symfony welcome page, which you can see in the image below.

Default Symfony route

Create the expense entity

The next thing to create is an entity to represent expenses. An expense will have the following fields:

  1. The amount
  2. The date it was incurred
  3. The status of the expense; could be either disbursed, pending, or disputed
  4. The owner of the expense

Create the entity by running the following command.

symfony console make:entity Expense

The CLI will ask questions and add fields for the entity based on the provided answers. Answer the questions as shown below.

 created: src/Entity/Expense.php
 created: src/Repository/ExpenseRepository.php

 Entity generated! Now let's add some fields!
 You can always add more fields later manually or by re-running this command.

 New property name (press <return> to stop adding fields):
 > amount

 Field type (enter ? to see all types) [string]:
 > float

 Can this field be null in the database (nullable) (yes/no) [no]:
 > no

 updated: src/Entity/Expense.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 > incurredOn

 Field type (enter ? to see all types) [string]:
 > datetime

 Can this field be null in the database (nullable) (yes/no) [no]:
 > no

 updated: src/Entity/Expense.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 > status

 Field type (enter ? to see all types) [string]:
 > string

 Field length [255]:
 > 255

 Can this field be null in the database (nullable) (yes/no) [no]:
 > no

 updated: src/Entity/Expense.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 > owner

 Field type (enter ? to see all types) [string]:
 > string

 Field length [255]:
 > 255

 Can this field be null in the database (nullable) (yes/no) [no]:
 > no

 updated: src/Entity/Expense.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 >

Press the "Enter" key to complete the creation process. Next, generate a database migration file to create the initial database structure and run it, by running the commands below.

symfony console make:migration
symfony console doctrine:migrations:migrate

Note: The migration file is generated using the Entity (src/Entity/Expense.php) that we just created and is stored in the migrations directory. Migration file names begin with Version followed by the date and time in the format yyyymmddhhmmss. For example Version20210610091304.php.

Finally, let's add constants to represent the status of an expense as well as a constructor to the Expense entity to make it easier to instantiate new expenses. To do that, open src/Entity/Expense.php and add the following code after private $owner;.

const DISBURSED = 'disbursed';
const PENDING   = 'pending';
const DISPUTED  = 'disputed';

public function __construct($amount, $incurredOn, $status, $owner) 
{
    $this->amount = $amount;
    $this->incurredOn = $incurredOn;
    $this->status = $status;
    $this->owner = $owner;
}

Create the expense fixture

To ease the process of adding dummy expenses to the database, let's create a fixture (src/DataFixtures/ExpenseFixtures.php) using the following command.

symfony console make:fixture ExpenseFixtures

Open the file and update it to match the code below.

<?php

declare(strict_types=1);

namespace App\DataFixtures;

use App\Entity\Expense;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
use Faker\Factory;
use Faker\Generator;

class ExpenseFixtures extends Fixture 
{
    private Generator $faker;

    public function __construct() 
    {
        $this->faker = Factory::create();
    }

    public function load(ObjectManager $manager) 
    {
        for ($i = 0; $i < 1000; $i++) {
            $manager->persist($this->getExpense());
        }

        $manager->flush();
    }

    private function getExpense(): Expense 
    {
        $possibleStatus = [Expense::DISBURSED, Expense::DISPUTED, Expense::PENDING];

        return new Expense(
            $this->faker->numberBetween(10000, 100000000),
            $this->faker->dateTimeThisYear(),
            $this->faker->randomElement($possibleStatus),
            $this->faker->name()
        );
    }
}

Load the fixtures using the following command.

symfony console doctrine:fixtures:load -n

We can verify that there are indeed expenses saved to the database by running the following command.

symfony console doctrine:query:dql --hydrate=array --max-result=2 "SELECT e from App:Expense e"

You should see the list of users as shown in the example output below.

array (size=2)
  0 => array (size=5)
          'id' => int 1
          'amount' => float 59445000
          'incurredOn' =>
            object(stdClass)[200]
              public '__CLASS__' => string 'DateTime' (length=8)
              public 'date' => string '2021-02-03T12:14:58+00:00' (length=25)
              public 'timezone' => string 'UTC' (length=3)
          'status' => string 'disputed' (length=8)
          'owner' => string 'Maritza Dach' (length=12)
  1 => array (size=5)
          'id' => int 2
          'amount' => float 65356375
          'incurredOn' =>
            object(stdClass)[344]
              public '__CLASS__' => string 'DateTime' (length=8)
              public 'date' => string '2021-01-19T11:15:49+00:00' (length=25)
              public 'timezone' => string 'UTC' (length=3)
          'status' => string 'disputed' (length=8)
          'owner' => string 'Krystina Anderson I' (length=19)

Building the report generator

Now that we have an initialized and loaded database, let's write a service that will help us create the expense report. In the src directory, create a directory named "Service" using the command below.

mkdir src/Service

Then, in src/Service, create a new file named ExpenseReportGenerator.php by using the command below.

touch src/Service/ExpenseReportGenerator.php

Note: if you're using Windows, use the command below instead, as touch isn't available on Windows.

type nul > src\Service\ExpenseReportGenerator.php

With the file created, open it  and add the following code to it.

<?php

namespace App\Service;

use App\Entity\Expense;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Color;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Yectep\PhpSpreadsheetBundle\Factory;

class ExpenseReportGenerator 
{
    private Factory $excelBundle;
    private Spreadsheet $spreadsheet;
    private int $currentIndex = 1;
    private string $reportBasePath;
    private ?string $reportPath = null;

    public function __construct(
        Factory $excelBundle,
        ParameterBagInterface $parameterBag
    ) 
    {
        $this->excelBundle = $excelBundle;
        $this->spreadsheet = $excelBundle->createSpreadsheet();
        $this->spreadsheet->setActiveSheetIndex(0);
        $this->reportBasePath = $parameterBag->get('generated_report_base_path');
    }

    public function generateExpenseReport(array $expenses) 
    {
        $this->writeHeader();
        foreach ($expenses as $expense) {
            $this->writeRow($expense);
        }
        $this->resizeColumns();
        $this->saveReport();
    }

    private function writeHeader() 
    {
        $this
            ->spreadsheet
            ->getActiveSheet()
            ->setCellValue("A$this->currentIndex", "S/N")
            ->setCellValue("B$this->currentIndex", "Owner")
            ->setCellValue("C$this->currentIndex", "Amount in ₦")
            ->setCellValue("D$this->currentIndex", "Incurred On")
            ->setCellValue("E$this->currentIndex", "Status")
            ->getStyle("A$this->currentIndex:E$this->currentIndex")
            ->getFont()
            ->setBold(true);
        $this->applyThinBorder("A$this->currentIndex:E$this->currentIndex");
        $this->currentIndex++;
    }

    private function applyThinBorder(string $range) 
    {
        $this
            ->spreadsheet
            ->getActiveSheet()
            ->getStyle($range)
            ->applyFromArray(
                [
                    'borders'   => [
                        'allBorders' => [
                            'borderStyle' => Border::BORDER_THIN,
                            'color'       => [
                                'argb' => Color::COLOR_BLACK
                            ],
                        ],
                    ],
                    'alignment' => [
                        'horizontal' => Alignment::HORIZONTAL_CENTER,
                    ],
                ]
            );
    }

    private function writeRow(Expense $expense) 
    {
        $this
            ->spreadsheet
            ->getActiveSheet()
            ->setCellValue("A$this->currentIndex", $this->currentIndex - 1)
            ->setCellValue("B$this->currentIndex", $expense->getOwner())
            ->setCellValue("C$this->currentIndex", number_format($expense->getAmount(), 2))
            ->setCellValue("D$this->currentIndex", $expense->getIncurredOn()->format('l jS F, Y'))
            ->setCellValue("E$this->currentIndex", ucfirst($expense->getStatus()));
        $this->applyThinBorder("A$this->currentIndex:E$this->currentIndex");
        $this->currentIndex++;
    }

    private function resizeColumns() 
    {
        $columns = ['B', 'C', 'D', 'E'];
        foreach ($columns as $column) {
            $this->spreadsheet
                ->getActiveSheet()
                ->getColumnDimension($column)
                ->setWidth(50);
        }
    }

    private function saveReport() 
    {
        $this->reportPath = "$this->reportBasePath/Expense Report " . time() . ".xlsx";
        $writer = $this->excelBundle->createWriter($this->spreadsheet, 'Xlsx');
        $writer->save($this->reportPath);
    }

    public function getReportPath(): string 
    {
        return $this->reportPath;
    }
}

Using dependency injection, we pass the PHPSpreadsheetBundle factory and also retrieve the base directory path (generated_report_base_path) from the container parameters. This is where all the generated reports are to be saved.

Next, we declare a function named generateExpenseReport which iterates through the provided array of expenses and writes their values to the next available row in the spreadsheet. It then formats the spreadsheet and saves it in the base directory for reports.

A getReportPath function is also provided which returns the path to the newly generated report. We'll use this to show the user where the report has been saved. We will also use it to add an attachment for the email.

For this to work, we need to create a base directory and let Symfony know where it is located. Create a directory named reports at the root of the project by running the command below.

mkdir reports

Next, add a parameter named generated_report_base_path to the parameters element in config/services.yaml, as shown below.

parameters:
    generated_report_base_path: '%kernel.project_dir%/reports'

Building the command

Instead of having to log in, navigate to a page dedicated to reports, and clicking a "download" button, wouldn't it be nice if we could just type a command for that - just like we have been doing so far?

Let's do this by creating a command to help with generating the reports, by running the following command.

symfony console make:command GenerateExpenseReportCommand

This will create a new file called GenerateExpenseReportCommand.php in src/Command. Open it and update it to match the following code.

<?php

namespace App\Command;

use App\Repository\ExpenseRepository;
use App\Service\ExpenseReportGenerator;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

class GenerateExpenseReportCommand extends Command 
{
    protected static $defaultName = 'generateExpenseReport';

    private ExpenseRepository $expenseRepository;
    private ExpenseReportGenerator $reportGenerator;

    public function __construct(
        ExpenseRepository $expenseRepository,
        ExpenseReportGenerator $reportGenerator
    ) 
    {
        parent::__construct();
        $this->expenseRepository = $expenseRepository;
        $this->reportGenerator = $reportGenerator;
    }

    protected function configure(): void 
    {
        $this
            ->setDescription('Generates an expense report')
            ->setHelp('This command helps you generate an expense report based on provided arguments')
        ;
    }

    protected function execute(InputInterface $input, OutputInterface $output): int 
    {
        $io = new SymfonyStyle($input, $output);

        $expenses = $this->expenseRepository->findAll();
        $this->reportGenerator->generateExpenseReport($expenses);

        $filepath = $this->reportGenerator->getReportPath();
        $io->success("Expense report saved to $filepath");

        return Command::SUCCESS;
    }
}

The $defaultName variable is the name of the command. This is what we type after symfony console in our CLI.

Next, we inject the dependencies for the command which are the ExpenseRepository and our newly created ExpenseReportGenerator service.

We use the configure function to define a description and help message for our command. We can also use it to add input options and arguments for our command, which we will be doing later on.

The execute function is where the expenses are retrieved from the database and passed to the ExpenseReportGenerator. It also has access to the command line which makes it possible for us to display a success message once the report has been generated successfully.

The execute function requires that we return an int corresponding to the exit status code. For now, we'll just return a success code (0).

With this in place, let's see our command in action, by running it, as in the example below.

symfony console generateExpenseReport

You should see a response similar to the screenshot below after a few seconds.

Generate an expense report

Navigate to the location returned in the success message and open the report. It should look like the one shown below.

The generated expense report

Adding options to our command

At the moment even though our CLI command works, it's not very flexible. For example, what if there's a requirement for the report to contain only expenses in dispute? Currently, we would have to manually filter the spreadsheet. Alternatively, what if we needed to send the report to someone? Isn't there an easier way to go about it than having to manually create an email and attach the report?

By adding options to our command, we make it easier to specify an additional request, such as sending the generated report to a provided email address, or the ability to create a filtered report, e.g., only including disputed expenses in the report.

In this section of the tutorial, we'll add two optional options to our command:

  1. --status or -s. When specified, only expenses with a matching status will be included in the report. The available option values are "disbursed", "disputed", and "pending".
  2. --mailTo or -m. When specified, an email containing the generated report will be sent to the provided email address.

To add these options, modify the configure function in src/Command/GenerateExpenseReportCommand.php to match the following code.

protected function configure(): void 
{
    $this
        ->setDescription('Generates an expense report')
        ->setHelp(
            'This command helps you generate an expense report based on provided arguments'
        )
        ->setDefinition(
            new InputDefinition(
                [
                    new InputOption(
                        'status',
                        's',
                        InputOption::VALUE_OPTIONAL,
                        'Only include expenses matching the specified status'
                    ),
                    new InputOption(
                        'mailTo',
                        'm',
                        InputOption::VALUE_OPTIONAL,
                        'Send the report as an attachment to the specified email address'
                    )
                ]
            )
        );
}

Note: Remember to add the import statements from InputDefinition and InputOption.

use Symfony\Component\Console\Input\InputDefinition;
use Symfony\Component\Console\Input\InputOption;

Notice that we added the two new options (status and mailTo) and specified that they are not required. We also provided shortcuts (s and m respectively) to save us the trouble of having to type the option in full. This means that once we complete the implementation, we will be able to send a report of disputed expenses to the legal team by running the following command.

symfony console generateExpenseReport -s disputed -m legal@test.com

Note: Some option shortcuts may be taken by default and trying to use them will cause an error. To view the options already in use, run the symfony console generateExpenseReport --help command. You should see output as in the screenshot below.

View generateExpenseReport&#x27;s default options

Making the status option work

To make the status option work, we need to retrieve the provided input and use it as a filter in our ExpenseRepository. However, we also need to guard against unknown status options so let's start from here. In src, create a new directory called Exception which will hold our custom exceptions by running the following command.

mkdir src/Exception

Then, in src/Exception, create a new file called UnknownExpenseStatusException.php by running the following command.

touch src/Exception/UnknownExpenseStatusException.php

If you're using Windows, run the following command

type nul > src\Exception\UnknownExpenseStatusException.php

Open src/Exception/UnknownExpenseStatusException.php and add the following code.

<?php

namespace App\Exception;

use Exception;

class UnknownExpenseStatusException extends Exception {

}

While this class is empty, it still helps us differentiate our custom exceptions from others, allowing us to customize our approach to this issue without affecting any other exception that may arise as the command is being executed.

Next, open src/Entity/Expense.php and add the following function after the class constructor.

public static function verifyExpenseStatus(string $status):void 
{
    if (!in_array($status, [self::PENDING, self::DISPUTED, self::DISBURSED])) {
        throw new UnknownExpenseStatusException(
            "Unknown expense status '$status' provided"
        );
    }
}

Don't forget to add the required use statement as well.

use App\Exception\UnknownExpenseStatusException;

This function helps us verify that the status provided to the function is valid. We put the function in the Expense entity to make it easier to find, should we need to modify the function. We also add the static keyword so that the function can be called without instantiating an Expense entity.

Next open src/Repository/ExpenseRepository.php and add the following function after the class constructor.

public function findByStatus(?string $status): array 
{
    if (is_null($status)) {
        return $this->findAll();
    }

    Expense::verifyExpenseStatus($status);

    return $this->findBy(['status' => $status]);
}

This function takes an optional expense status ($status), which can be null. If it is not provided, then the function returns all the expenses in the database.

If $status was provided, the function verifies the value using the verifyExpenseStatus function which we declared earlier. If the status is invalid, an UnknownExpenseStatusException will be thrown, otherwise the findBy function will be used to return a filtered expense list based on the provided status.

Finally, update the execute function in src/Command/GenerateExpenseReportCommand.php to match the following code.

protected function execute(InputInterface $input, OutputInterface $output): int 
{
    $io = new SymfonyStyle($input, $output);
    $status = $input->getOption('status');

    try {
        $expenses = $this->expenseRepository->findByStatus($status);
        $this->reportGenerator->generateExpenseReport($expenses);
        $filepath = $this->reportGenerator->getReportPath();
        $io->success("Expense report saved to $filepath");

        return Command::SUCCESS;
    }
    catch (UnknownExpenseStatusException $ex) {
        $io->error($ex->getMessage());

        return Command::INVALID;
    }
}

Don't forget to add the required use statement as well.

use App\Exception\UnknownExpenseStatusException;

Notice the 3 major changes we made:

  1. We retrieve the status option from the InputInterface object ($input). If the user did not specify a status then $status will be null.
  2. Instead of using the findAll function for our ExpenseRepository, we use our newly defined findByStatus function to get the expenses that should go into the report.
  3. We wrap the code with a try block and specify a catch block in the event that an UnknownExpenseStatusException is thrown. In this block we output the exception message as an error in the CLI and return the INVALID exit status code (2).

Now let's try generating a report for an invalid status. Try the following command, which will attempt to filter on an invalid expense type.

symfony console generateExpenseReport -s dummy

You should see output similar to the following.

Default command output

Now run the command again, but this time let's pass a status option of disputed.

symfony console generateExpenseReport -s disputed

This time, the command exits successfully and if we open the newly generated report, we see that it only contains disputed expenses.

Default list of records

All that's left is for us to enhance our command so that it can send the report as an email for us.

Setting up sender email on SendGrid

To send our emails, we'll be using Twilio SendGrid. Log in to your account and set up a sender identity. If you already have one, you can skip to the next section where we will use the SendGrid credentials in our Symfony application.

For this tutorial, we will take the Single Sender Verification approach in setting up a sender identity. Head to the SendGrid console to start the process and fill the form displayed on the screen.

Create a SendGrid sender, step 1.

A verification email will be sent to the email address you provided in the form. Click on the verification link to complete the verification process.

Verify SendGrid sender

Once the verification process is completed, we will need an API key that will be used to authenticate requests made to the API. To do this, head to the API Keys section and click the Create API Key button.

Create a SendGrid API key, step 1.

Fill the displayed form and click the Create & View button in order to view your API Key

Create a SendGrid API key, step 2

The next screen will show your API key.

Note: For security reasons, it will only be shown once so make sure you copy it before clicking DONE.

Making the mailTo option work

Now that we have a sender and an API key to authenticate our requests, we can add the last piece of our puzzle: a feature to send the generated report as an email attachment.

Before we jump into the code, let's add our API key, sender email, and sender name to the application. To do that, open .env.local and add the following to it, replacing the placeholders with applicable values.

SENDGRID_API_KEY=<YOUR_SENDGRID_API_KEY>
SENDGRID_SENDER_EMAIL=<YOUR_SENDGRID_SENDER_EMAIL>
SENDGRID_SENDER_NAME=<YOUR_SENDGRID_SENDER_NAME>

Next, add both keys to the parameter container. Open config/services.yaml and update the parameters key to match the following.

parameters:
    generated_report_base_path: '%kernel.project_dir%/reports'
    sendgrid_api_key: '%env(SENDGRID_API_KEY)%'
    sendgrid_sender_email: '%env(SENDGRID_SENDER_EMAIL)%'
    sendgrid_sender_name: '%env(SENDGRID_SENDER_NAME)%'

Next, in src/Service, create a new file called SendGridMailer.php by running the command below. This service will handle the sending of emails via Twilio SendGrid.

touch src/Service/SendGridMailer.php

If you're on Windows, run this command instead.

type nul > src\Service\SendGridMailer.php

Then, open src/Service/SendGridMailer.php and add the following code.

<?php

namespace App\Service;

use SendGrid;
use SendGrid\Mail\Mail;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;

class SendGridMailer 
{
    private string $apiKey;
    private string $senderEmail;
    private string $senderName;

    public function __construct(ParameterBagInterface $parameterBag) 
    {
        $this->apiKey = $parameterBag->get('sendgrid_api_key');
        $this->senderEmail = $parameterBag->get('sendgrid_sender_email');
        $this->senderName = $parameterBag->get('sendgrid_sender_name');
    }

    public function sendMail(
        string $recipientEmail,
        string $subject,
        string $htmlContent,
        ?string $attachmentPath
    ) 
    {
        $email = new Mail();
        $email->setFrom($this->senderEmail, $this->senderName);
        $email->setSubject($subject);
        $email->addTo($recipientEmail);
        $email->addContent("text/html", $htmlContent);

        if (!is_null($attachmentPath)) {
            $attachment = base64_encode(file_get_contents($attachmentPath));
            $email->addAttachment(
                $attachment,
                "application/octet-stream",
                basename($attachmentPath),
                "attachment"
            );
        }
        $sendgrid = new SendGrid($this->apiKey);
        $sendgrid->send($email);
    }
}

To get started, we access the parameter container to get the API key, sender email, and sender name. Next, we declare a function called sendMail which handles the actual sending of the mail using the parameters provided.

With the service in place, we can modify our command one last time. Open GenerateExpenseReportCommand.php and modify the private fields and constructor to match the following.

    private ExpenseRepository $expenseRepository;
    private ExpenseReportGenerator $reportGenerator;
    private SendGridMailer $mailer;

    public function __construct(
        ExpenseRepository $expenseRepository,
        ExpenseReportGenerator $reportGenerator,
        SendGridMailer $mailer
    ) 
    {
        parent::__construct();
        $this->expenseRepository = $expenseRepository;
        $this->reportGenerator = $reportGenerator;
        $this->mailer = $mailer;
    }

Note: Remember to include the import statement for the SendGridMailer.

use App\Service\SendGridMailer;

Next, modify the execute function to match the following.

protected function execute(InputInterface $input, OutputInterface $output): int 
{
    $io = new SymfonyStyle($input, $output);
    $status = $input->getOption('status');
    $recipientEmailAddress = $input->getOption('mailTo');

    try {
        $expenses = $this->expenseRepository->findByStatus($status);
        $this->reportGenerator->generateExpenseReport($expenses);
        $filepath = $this->reportGenerator->getReportPath();
        $io->success("Expense report saved to $filepath");

        if (!is_null($recipientEmailAddress)) {
           $message =
                    <<<EOF
<html lang="en">
<body>
<p>Hi there,</p>
<p>Here's the latest expense report generated via the CLI</p>
</body>
</html>
EOF;
            $this->mailer->sendMail($recipientEmailAddress, 'Expense Report', $message, $filepath);
            $io->success("Expense report sent successfully to $recipientEmailAddress");
        }

        return Command::SUCCESS;
    }
    catch (UnknownExpenseStatusException $ex) {
        $io->error($ex->getMessage());

        return Command::INVALID;
    }
}

Here we check if an email address was provided to the mailTo option. If one was provided, we create an HTML message and pass that to the sendMail function along with the email address, the subject of the email, and the path to the generated report.

Once the mail has been sent, we display a success message and return the Command::SUCCESS exit status code.

Let's test the changes by running the command, which emails just the disputed expenses to a specified email address.

symfony console generateExpenseReport -s disputed  -m <SPECIFY_EMAIL_HERE>

Run the generateExpenseReport command

Head to your inbox where your email and newly generated report are waiting for you.

View the generated expense report in an email client

Automating the report generation process

While you're still patting yourself on the back you receive a new feature request. The finance team needs the list of pending expenses emailed to them at the start of every working day to help them plan their disbursements for the day. Suddenly running the same command every day has lost its appeal.

Well, assuming that you're using a *NIX operating system (Linux, *BSD, or macOS) another beauty of commands is that they can be used in combination with a cron job to automate tasks. All you need to do is add the appropriate command to your cron table (crontab) and everything else will be taken care of for you.

To do that, edit your crontab using the following command.

crontab -e

This will display the cron table with the current list of instructions in the command line. At the bottom of the table add the following line

30 8 * * * php /var/www/expense-management-cli/bin/console generateExpenseReport -s pending  -m financeteam@abc.com

Save the changes and exit the text editor.

This command lets the cron scheduler know that this command is to be run every day at 8:30 a.m. The day is saved and you're free to enjoy your morning coffee without disturbance. You've certainly earned it!

Note: if you're using Windows, the Task Scheduler provides equivalent functionality.

Conclusion

CLI tools make our development experience more enjoyable as the mundane aspects such as file generation and service configuration are abstracted away, allowing the developer to focus on issues such as business logic and the likes.

But it doesn't have to end there as you can build your own CLI tool to handle mundane business processes like report generation and the likes. You can even take a step further and automate such processes which has the added benefit of saving you time but also reducing the risk of human error which tends to occur with repetitive tasks.

We demonstrated all that by building our own CLI tool to assist with report generation as well as automating it to run without us having to manually type the command by using cron.

You can review the final codebase on GitHub. Until next time, bye for now.

Bio

Joseph Udonsak is a software engineer with a passion for solving challenges – be it building applications, or conquering new frontiers on Candy Crush. When he’s not staring at his screens, he enjoys a cold beer and laughs with his family and friends.