DEV Community

Cover image for Purging Expired Carts | Building a Shopping Cart with Symfony
Quentin Ferrer
Quentin Ferrer

Posted on • Edited on

Purging Expired Carts | Building a Shopping Cart with Symfony


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

Generating the command

Use the Maker bundle to generate the command:

$ symfony console make:command RemoveExpiredCartsCommand
Enter fullscreen mode Exit fullscreen mode

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

<?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;
    }
}

Enter fullscreen mode Exit fullscreen mode

Configuring the Command

In the RemoveExpiredCartsCommand class, set the default command name:

protected static $defaultName = 'app:remove-expired-carts';
Enter fullscreen mode Exit fullscreen mode

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.
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
        )
    ;
}
Enter fullscreen mode Exit fullscreen mode

Writing the Logic of the Command

Finding Expired Carts

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

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()
        ;
    }
}
Enter fullscreen mode Exit fullscreen mode

Doctrine provides a Query Builder 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 acart 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

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

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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

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. 
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (2)

Collapse
 
rekcoob profile image
Marian Ivanovic

Hello Quentin.
Thank you for an excellent tutorial.
This is realy good intermediate content from great symfony developer.
I can't wait for more. Good Job

Btw I 've just noticed small mistake.
You forgot to add parent::__construct() in RemoveExpiredCartsCommand constructor.

Collapse
 
qferrer profile image
Quentin Ferrer

Glad you like it! Thanks a lot, I fixed it! I also checked the source code on github, it's correct. Enjoy the rest of this tutorial.