Let's Make a Console Command!

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

We've created exactly one email... and done some pretty cool stuff with it. Let's introduce a second email... but with a twist: instead of sending this email when a user does something on the site - like register - we're going to send this email from a console command. And that... changes a few things.

Let's create the custom console command first. Here's my idea: one of the fields on User is called $subscribeToNewsletter. In our pretend app, if this field is set to true for an author - someone that writes content on our site - once a week, via a CRON job, we'll run a command that will email them an update on what they published during the last 7 days.

Making the Command

Let's bootstrap the command... the lazy way. Find your terminal and run:

php bin/console make:command

Call it app:author-weekly-report:send. Perfect! Back in the editor, head to the src/Command directory to find... our shiny new console command.

<?php
namespace App\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
class AuthorWeeklyReportSendCommand extends Command
{
protected static $defaultName = 'app:author-weekly-report:send';
protected function configure()
{
$this
->setDescription('Add a short description for your command')
->addArgument('arg1', InputArgument::OPTIONAL, 'Argument description')
->addOption('option1', null, InputOption::VALUE_NONE, 'Option description')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$arg1 = $input->getArgument('arg1');
if ($arg1) {
$io->note(sprintf('You passed an argument: %s', $arg1));
}
if ($input->getOption('option1')) {
// ...
}
$io->success('You have a new command! Now make it your own! Pass --help to see your options.');
return 0;
}
}

Let's start customizing this: we don't need any arguments or options... and I'll change the description:

Send weekly reports to authors.

... lines 1 - 12
class AuthorWeeklyReportSendCommand extends Command
{
... lines 15 - 25
protected function configure()
{
$this
->setDescription('Send weekly reports to authors')
;
}
... lines 32 - 46
}

The first thing we need to do is find all users that have this $subscribeToNewsletter property set to true in the database. To keep our code squeaky clean, let's add a custom repository method for that in UserRepository. How about public function findAllSubscribedToNewsletter(). This will return an array.

... lines 1 - 14
class UserRepository extends ServiceEntityRepository
{
... lines 17 - 49
public function findAllSubscribedToNewsletter(): array
{
... lines 52 - 55
}
... lines 57 - 85
}

Inside, return $this->createQueryBuilder(), u as the alias, ->andWhere('u.subscribeToNewsletter = 1'), ->getQuery() and ->getResult().

... lines 1 - 14
class UserRepository extends ServiceEntityRepository
{
... lines 17 - 49
public function findAllSubscribedToNewsletter(): array
{
return $this->createQueryBuilder('u')
->andWhere('u.subscribeToNewsletter = 1')
->getQuery()
->getResult();
}
... lines 57 - 85
}

Above the method, we can advertise that this specifically returns an array of User objects.

... lines 1 - 14
class UserRepository extends ServiceEntityRepository
{
... lines 17 - 46
/**
* @return User[]
*/
public function findAllSubscribedToNewsletter(): array
{
return $this->createQueryBuilder('u')
->andWhere('u.subscribeToNewsletter = 1')
->getQuery()
->getResult();
}
... lines 57 - 85
}

Autowiring Services into the Command

Back in the command, let's autowire the repository by adding a constructor. This is one of the rare cases where we have a parent class... and the parent class has a constructor. I'll go to the Code -> Generate menu - or Command + N on a Mac - and select "Override methods" to override the constructor.

Notice that this added a $name argument - that's an argument in the parent constructor - and it called the parent constructor. That's important: the parent class needs to set some stuff up. But, we don't need to pass the command name: Symfony already gets that from a static property on our class. Instead, make the first argument: UserRepository $userRepository. Hit Alt + Enter and select "Initialize fields" to create that property and set it. Perfect.

... lines 1 - 4
use App\Repository\UserRepository;
... lines 6 - 12
class AuthorWeeklyReportSendCommand extends Command
{
... lines 15 - 16
private $userRepository;
public function __construct(UserRepository $userRepository)
{
parent::__construct(null);
$this->userRepository = $userRepository;
}
... lines 25 - 46
}

Next, in execute(), clear everything out except for the $io variable, which is a nice little object that helps us print things and interact with the user... in a pretty way.

... lines 1 - 12
class AuthorWeeklyReportSendCommand extends Command
{
... lines 15 - 32
protected function execute(InputInterface $input, OutputInterface $output)
{
$io = new SymfonyStyle($input, $output);
... lines 36 - 45
}
}

Start with $authors = $this->userRepository->findAllSubscribedToNewsletter().

... lines 1 - 12
class AuthorWeeklyReportSendCommand extends Command
{
... lines 15 - 32
protected function execute(InputInterface $input, OutputInterface $output)
{
$io = new SymfonyStyle($input, $output);
$authors = $this->userRepository
->findAllSubscribedToNewsletter();
... lines 39 - 45
}
}

Well, this really returns all users... not just authors - but we'll filter them out in a minute. To be extra fancy, let's add a progress bar! Start one with $io->progressStart(). Then, foreach over $authors as $author, and advance the progress inside.

... lines 1 - 12
class AuthorWeeklyReportSendCommand extends Command
{
... lines 15 - 32
protected function execute(InputInterface $input, OutputInterface $output)
{
$io = new SymfonyStyle($input, $output);
$authors = $this->userRepository
->findAllSubscribedToNewsletter();
$io->progressStart(count($authors));
foreach ($authors as $author) {
$io->progressAdvance();
}
... lines 43 - 45
}
}

Oh, and of course, for progressStart(), I need to tell it how many data points we're going to advance. Use count($authors). Leave the inside of the foreach empty for now, and after, say $io->progressFinish(). Finally, for a big happy message, add $io->success()

Weekly reports were sent to authors!

... lines 1 - 12
class AuthorWeeklyReportSendCommand extends Command
{
... lines 15 - 32
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$authors = $this->userRepository
->findAllSubscribedToNewsletter();
$io->progressStart(count($authors));
foreach ($authors as $author) {
$io->progressAdvance();
}
$io->progressFinish();
$io->success('Weekly reports were sent to authors!');
return 0;
}
}

Brilliant! We're not doing anything yet... but let's try it! Copy the command name, find your terminal, and do it!

php bin/console app:author-weekly-report:send

Super fast!

Counting Published Articles

Inside the foreach, the next step is to find all the articles this user published - if any - from the past week. Open up ArticleRepository... and add a new method for this - findAllPublishedLastWeekByAuthor() - with a single argument: the User object. This will return an array... of articles: let's advertise that above.

... lines 1 - 5
use App\Entity\User;
... lines 7 - 16
class ArticleRepository extends ServiceEntityRepository
{
... lines 19 - 37
/**
* @return Article[]
*/
public function findAllPublishedLastWeekByAuthor(User $author): array
{
... lines 43 - 49
}
... lines 51 - 73
}

The query itself is pretty simple: return $this->createQueryBuilder() with ->andWhere('a.author = :author) to limit to only this author - we'll set the :author parameter in a second - then ->andWhere('a.publishedAt > :week_ago'). For the placeholders, call setParameter() to set author to the $author variable, and ->setParameter() again to set week_ago to a new \DateTime('-1 week'). Finish with the normal ->getQuery() and ->getResult().

... lines 1 - 16
class ArticleRepository extends ServiceEntityRepository
{
... lines 19 - 37
/**
* @return Article[]
*/
public function findAllPublishedLastWeekByAuthor(User $author): array
{
return $this->createQueryBuilder('a')
->andWhere('a.author = :author')
->andWhere('a.publishedAt > :week_ago')
->setParameter('author', $author)
->setParameter('week_ago', new \DateTime('-1 week'))
->getQuery()
->getResult();
}
... lines 51 - 73
}

Boom! Back in the command, autowire the repository via the second constructor argument: ArticleRepository $articleRepository. Hit Alt + Enter to initialize that field.

... lines 1 - 4
use App\Repository\ArticleRepository;
... lines 6 - 13
class AuthorWeeklyReportSendCommand extends Command
{
... lines 16 - 18
private $articleRepository;
public function __construct(UserRepository $userRepository, ArticleRepository $articleRepository)
{
... lines 23 - 25
$this->articleRepository = $articleRepository;
}
... lines 28 - 56
}

Down in execute, we can say $articles = $this->articleRepository->findAllPublishedLastWeekByAuthor() and pass that $author.

... lines 1 - 13
class AuthorWeeklyReportSendCommand extends Command
{
... lines 16 - 35
protected function execute(InputInterface $input, OutputInterface $output)
{
... lines 38 - 42
foreach ($authors as $author) {
$io->progressAdvance();
$articles = $this->articleRepository
->findAllPublishedLastWeekByAuthor($author);
... lines 48 - 51
}
... lines 53 - 55
}
}

Phew! Because we're actually querying for all users, not everyone will be an author... and even less will have authored some articles in the past 7 days. Let's skip those to avoid sending empty emails: if count($articles) is zero, then continue.

... lines 1 - 13
class AuthorWeeklyReportSendCommand extends Command
{
... lines 16 - 35
protected function execute(InputInterface $input, OutputInterface $output)
{
... lines 38 - 42
foreach ($authors as $author) {
$io->progressAdvance();
$articles = $this->articleRepository
->findAllPublishedLastWeekByAuthor($author);
// Skip authors who do not have published articles for the last week
if (count($articles) === 0) {
continue;
}
}
... lines 53 - 55
}
}

By the way, in a real app, where you would have hundreds, thousands or even more users, querying for all that have subscribed is not going to work. Instead, I would make my query smarter by only returning users that are authors or even query for a limited number of authors, keep track of which you've sent to already, then run the command over and over again until everyone has gotten their update. These aren't even the only options. The point is: I'm being a little loose with how much data I'm querying for: be careful in a real app.

Ok, I think we're good! I mean, we're not actually emailing yet, but let's make sure it runs. Find your terminal and run the command again:

php bin/console app:author-weekly-report:send

All smooth. Next... let's actually send an email! And then, fix the duplication we're going to have between our two email templates.

Leave a comment!

  • 2020-03-09 weaverryan

    Hey nfleming65!

    Yes, this *exactly* happened in Symfony 5 (not returning an integer became deprecated in Symfony 4.4. We'll add a note to this tutorial - you should start returning it in 4.4, but it even works to return it in earlier versions.

    Thanks!

  • 2020-03-07 nfleming65

    Was there a change in Symfony's command component? Now commands have to return an integer and won't run unless you have the return statement at the end.