Magento relies on cron for indexing, emails, cache cleaning, currency rates, sitemaps and much more. Your modules can schedule their own jobs in the same system. In this tutorial we create a nightly job that deactivates old FAQ entries, make its schedule configurable from the admin, and look at how to debug cron when jobs do not run.
Step 1: Make Sure Magento Cron Is Installed
Magento needs a single system cron entry that runs bin/magento cron:run every minute. The easiest way to install it is:
bin/magento cron:install
crontab -l
You should see an entry similar to * * * * * /usr/bin/php /var/www/html/bin/magento cron:run 2>&1 | grep -v "Ran jobs by schedule" >> /var/www/html/var/log/magento.cron.log. Run it as the same user that owns the Magento files.
Step 2: Declare the Job in crontab.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Cron:etc/crontab.xsd">
<group id="default">
<job name="mageservices_faq_deactivate_old"
instance="MageServices\Faq\Cron\DeactivateOldFaqs"
method="execute">
<config_path>mageservices_faq/cron/deactivate_schedule</config_path>
</job>
</group>
</config>
Use <schedule>0 3 * * *</schedule> for a fixed schedule, or <config_path> to read the cron expression from configuration so it can be changed without a deployment.
Step 3: Default Schedule in config.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Store:etc/config.xsd">
<default>
<mageservices_faq>
<cron>
<deactivate_schedule>0 3 * * *</deactivate_schedule>
<max_age_days>365</max_age_days>
</cron>
</mageservices_faq>
</default>
</config>
Step 4: The Job Class
<?php
declare(strict_types=1);
namespace MageServices\Faq\Cron;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Framework\App\ResourceConnection;
use Psr\Log\LoggerInterface;
class DeactivateOldFaqs
{
private const XML_PATH_MAX_AGE = 'mageservices_faq/cron/max_age_days';
public function __construct(
private readonly ResourceConnection $resourceConnection,
private readonly ScopeConfigInterface $scopeConfig,
private readonly LoggerInterface $logger
) {
}
public function execute(): void
{
$days = max(1, (int) $this->scopeConfig->getValue(self::XML_PATH_MAX_AGE));
$connection = $this->resourceConnection->getConnection();
$table = $this->resourceConnection->getTableName('mageservices_faq');
$affected = $connection->update(
$table,
['is_active' => 0],
[
'is_active = ?' => 1,
'updated_at < ?' => (new \DateTimeImmutable(sprintf('-%d days', $days)))->format('Y-m-d H:i:s'),
]
);
$this->logger->info(sprintf('Deactivated %d FAQ entries older than %d days.', $affected, $days));
}
}
Cron jobs run in the crontab area. If a job sends emails or renders templates, emulate the frontend area inside the job.
Step 5: Run and Check the Job
bin/magento cache:flush
bin/magento cron:run --group=default
bin/magento cron:run --group=default
The first run schedules jobs; the next run executes those that are due. Check results in the database:
SELECT job_code, status, scheduled_at, executed_at, finished_at, messages
FROM cron_schedule
WHERE job_code = 'mageservices_faq_deactivate_old'
ORDER BY schedule_id DESC
LIMIT 10;
Custom Cron Groups
Long-running jobs, such as imports, should not delay core jobs. Put them in their own group with cron_groups.xml and, optionally, run the group in a separate process:
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Cron:etc/cron_groups.xsd">
<group id="mageservices_imports">
<schedule_generate_every>1</schedule_generate_every>
<schedule_ahead_for>4</schedule_ahead_for>
<schedule_lifetime>15</schedule_lifetime>
<history_cleanup_every>10</history_cleanup_every>
<history_success_lifetime>60</history_success_lifetime>
<history_failure_lifetime>4320</history_failure_lifetime>
<use_separate_process>1</use_separate_process>
</group>
</config>
Debugging Cron
| Problem | What to check |
|---|---|
| Jobs never run | crontab -l for the Magento entry; the PHP path; file permissions. |
| Many "missed" jobs | Cron is not running every minute, or another job is blocking the group. |
| Jobs stuck in "running" | A job crashed or timed out. Fix the cause; stale rows are cleaned up by the history settings. |
| Indexers out of date | Indexers on "Update by Schedule" depend on cron. Check indexer_update_all_views. |
| Errors | var/log/cron.log, var/log/exception.log and the messages column. |
Preventing Overlapping Runs
If a job can take longer than its schedule interval, two runs may overlap. Use Magento's lock manager (Magento\Framework\Lock\LockManagerInterface) to acquire a named lock at the start of the job and release it in a finally block. If the lock is already held, exit early and log a message. For very long jobs, move the work to a message queue consumer and let cron only publish messages.
Frequently Asked Questions
How often should Magento cron run?
Every minute. Magento decides which jobs are due; running cron less often causes missed jobs and delayed indexing and emails.
Can I run a single cron job manually?
Not directly with core commands. Put the logic in a service class and call it from a custom CLI command for manual runs.
What is the difference between schedule and config_path?
schedule fixes the cron expression in code; config_path reads it from configuration, so it can be changed per environment or in the admin.
Need help with this on your store?
Our Magento engineers can implement it for you, review your code or take on the whole project.
Written by the Magento Services engineering team — Magento 2, Adobe Commerce and Hyvä specialists since 2014. We write about problems we solve on real client stores.