Purging Expired Carts | Building a Shopping Cart with Symfony
Tags: php, symfony, tutorial, console
Published: 2020-12-21 12:56:48

- [Creating the Command](#create-the-command)
  - [Generating the Command](#generate-the-command)
  - [Configuring the Command](#configure-the-command)
- [Writing the Logic of the Command](#write-the-logic-command)
  - [Finding Expired Carts](#find-expired-carts)
  - [Deleting Expired Carts](#delete-expired-carts)
- [Executing the Command](#execute-the-command)

--------

The carts are saved in the database. It is, therefore, necessary to purge the expired carts in order to avoid keeping old carts that are no longer used due to the expiration of the session. To do that, we will create a CLI command with the goal of running it every day in production automatically using a cron job.

Writing the Command <a name="create-the-command"></name>
----------------------

### Generating the command <a name="generate-command"></name>

Use the Maker bundle to generate the command:

```
$ symfony console make:command RemoveExpiredCartsCommand
```

The command creates a `RemoveExpiredCartsCommand` class under the `src/Command/` directory.

```php
<?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 RemoveExpiredCartsCommand extends Command
{
    protected static $defaultName = 'RemoveExpiredCartsCommand';

    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 Command::SUCCESS;
    }
}

```

### Configuring the Command <a name="configure-the-command"></name>

In the `RemoveExpiredCartsCommand` class, set the default command name:

```php
protected static $defaultName = 'app:remove-expired-carts';
```

And update the `configure()` method to:
- define a command description and,
- add an optional input argument to define the number of days a cart can remain inactive. By default, a cart will be deleted after 2 days of inactivity.

```php
protected function configure()
{
    $this
        ->setDescription('Removes carts that have been inactive for a defined period')
        ->addArgument(
            'days',
            InputArgument::OPTIONAL,
            'The number of days a cart can remain inactive',
            2
        )
    ;
}
```

Writing the Logic of the Command <a name="write-the-logic-command"></name>
----------------------

### Finding Expired Carts <a name="find-expired-carts"></name>
When we created the `OrderEntity` entity, a `OrderRepository` class was generated. 

> A repository helps you fetch entities of a certain class. 

Doctrine recommends centralizing all queries in this repository. So, let's add a method `findCartsNotModifiedSince` to find inactive cards since a period.

```php
<?php

namespace App\Repository;

use App\Entity\Order;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @method Order|null find($id, $lockMode = null, $lockVersion = null)
 * @method Order|null findOneBy(array $criteria, array $orderBy = null)
 * @method Order[]    findAll()
 * @method Order[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
 */
class OrderRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Order::class);
    }

    /**
     * Finds carts that have not been modified since the given date.
     *
     * @param \DateTime $limitDate
     * @param int $limit
     *
     * @return int|mixed|string
     */
    public function findCartsNotModifiedSince(\DateTime $limitDate, int $limit = 10): array
    {
        return $this->createQueryBuilder('o')
            ->andWhere('o.status = :status')
            ->andWhere('o.updatedAt < :date')
            ->setParameter('status', Order::STATUS_CART)
            ->setParameter('date', $limitDate)
            ->setMaxResults($limit)
            ->getQuery()
            ->getResult()
        ;
    }
}
```

Doctrine provides a [Query Builder](https://www.doctrine-project.org/projects/doctrine-orm/en/current/reference/query-builder.html) object to help you building a DQL query.

First, we add a filter to find the carts that have not been modified since the given limit date. 
Then, as a cart is an `Order` in a` cart` status, we don't forget to filter by status.
Finally, we might have a lot of expired carts, so we add a limit filter to avoid running out of memory.

### Deleting Expired Carts <a name="delete-expired-carts"></name>

The `execute()` method must contain the logic we want the command to execute. It must return an integer to inform the command status. Implement it to bulk delete expired carts based on the input argument value:

```php
<?php

namespace App\Command;

use App\Repository\OrderRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class RemoveExpiredCartsCommand extends Command
{
    /**
     * @var EntityManagerInterface 
     */
    private $entityManager;

    /**
     * @var OrderRepository
     */
    private $orderRepository;

    protected static $defaultName = 'app:remove-expired-carts';

    /**
     * RemoveExpiredCartsCommand constructor.
     *
     * @param EntityManagerInterface $entityManager
     * @param OrderRepository $orderRepository
     */
    public function __construct(EntityManagerInterface $entityManager, OrderRepository $orderRepository)
    {
        parent::__construct();
        $this->entityManager = $entityManager;
        $this->orderRepository = $orderRepository;
    }

    /**
     * @inheritDoc
     */
    protected function configure()
    {
        $this
            ->setDescription('Removes carts that have been inactive for a defined period')
            ->addArgument(
                'days',
                InputArgument::OPTIONAL,
                'The number of days a cart can remain inactive',
                2
            )
        ;
    }

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

        $days = $input->getArgument('days');

        if ($days <= 0) {
            $io->error('The number of days should be greater than 0.');
            return Command::FAILURE;
        }

        // Subtracts the number of days from the current date.
        $limitDate = new \DateTime("- $days days");
        $expiredCartsCount = 0;

        while($carts = $this->orderRepository->findCartsNotModifiedSince($limitDate)) {
            foreach ($carts as $cart) {
                // Items will be deleted on cascade
                $this->entityManager->remove($cart);
            }

            $this->entityManager->flush(); // Executes all deletions
            $this->entityManager->clear(); // Detaches all object from Doctrine

            $expiredCartsCount += count($carts);
        };

        if ($expiredCartsCount) {
            $io->success("$expiredCartsCount cart(s) have been deleted.");
        } else {
            $io->info('No expired carts.');
        }

        return Command::SUCCESS;
    }
}
```

We set the date limit by subtracting the number of days from the current date.
Next, we iterate and handle batches of carts instead of loading all the expired carts into memory. It's a good way to avoid running out of memory with bulk operations like this. The items of the cart have been deleted on cascade. 
Then, we write the number of expired carts we deleted to the console and return the command status.

Executing the Command <a name="execute-the-command"></name>
---------------------

After configuring and handling the command, you can run it in the terminal:

```
$ symfony console app:remove-expired-carts

[OK] 1 cart(s) have been deleted. 
```

If there are no expired carts, the command should write the following message in the output:

```
$ symfony console app:remove-expired-carts

[INFO] No expired carts.
```

Now you know how to create a CLI command in Symfony. Of course, this command should be executed automatically in production using a cron job but that is not the purpose of this tutorial.

There is one last step left to complete this tutorial. Let's test the cart in the final step.