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.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeWe'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.
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.
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.