Home Guides How-To Guides Cron Jobs Implementation: Building Reliable Scheduled Tasks in Magento 2
How-To

Cron Jobs Implementation: Building Reliable Scheduled Tasks in Magento 2

Complete guide to implementing cron jobs in Magento 2: crontab.xml configuration, cron group management, long-running job strategies, logging and monitoring, error handling, testing, and production deployment

Advanced 75 minutes Magento 2.4.7+

Cron Jobs Implementation: Building Reliable Scheduled Tasks in Magento 2

Learning Objectives

By completing this guide, you will:

  • Understand Magento 2's cron architecture and execution flow
  • Configure cron jobs via crontab.xml with proper scheduling
  • Implement cron job classes with robust error handling
  • Manage cron groups for resource isolation and scaling
  • Handle long-running jobs with proper timeout and chunking strategies
  • Implement comprehensive logging and monitoring
  • Test cron jobs in development and staging environments
  • Deploy and monitor cron jobs in production
  • Debug common cron issues (missed runs, overlapping executions, failures)

Introduction

Magento 2's cron system enables scheduled background tasks for indexing, cache cleaning, email sending, order processing, and custom business logic. Unlike server-level cron jobs, Magento's cron framework provides scheduling, error handling, locking, and monitoring within the application.

Magento Cron vs System Cron

System cron (Linux crontab): - Schedules Magento's cron runner - Runs: bin/magento cron:run - Frequency: Every minute (typical)

Magento cron (application-level): - Schedules individual jobs - Managed in crontab.xml - Executed by cron runner

Architecture:

System Cron (every 1 minute)
  ↓
bin/magento cron:run
  ↓
Cron Schedule Table (cron_schedule)
  ↓
Job Execution (per schedule entry)
  ↓
Job Class::execute()

Cron Groups

Magento organizes cron jobs into groups for resource management:

Group Purpose Jobs Frequency
default General tasks Cache cleaning, sitemap generation Every 1 minute
index Indexer updates Mview changelog processing Every 1 minute
consumers Message queue consumers Async operations, bulk API Continuous
catalog_event Catalog operations Scheduled product updates Every 1 minute

Benefits of groups: - Isolate resource-intensive jobs - Run groups on different servers - Control concurrency per group

Cron Job Configuration

Basic Cron Job

etc/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="vendor_module_cleanup_temp_data" instance="Vendor\Module\Cron\CleanupTempData" method="execute">
            <schedule>0 2 * * *</schedule><!-- Daily at 2:00 AM -->
        </job>
    </group>
</config>

Configuration elements: - group id: Cron group (default, index, custom) - job name: Unique identifier (module_name format) - instance: Cron job class (must implement execute() method) - method: Method to execute (default: execute) - schedule: Cron expression (minute, hour, day, month, weekday)

Cron Expression Syntax

Magento uses standard cron expression format:

* * * * *
│ │ │ │ │
│ │ │ │ └─ Day of week (0-6, Sunday=0)
│ │ │ └─── Month (1-12)
│ │ └───── Day of month (1-31)
│ └─────── Hour (0-23)
└───────── Minute (0-59)

Common patterns:

<!-- Every 5 minutes -->
<schedule>*/5 * * * *</schedule>

<!-- Every hour at minute 15 -->
<schedule>15 * * * *</schedule>

<!-- Daily at 3:30 AM -->
<schedule>30 3 * * *</schedule>

<!-- Mondays at 9:00 AM -->
<schedule>0 9 * * 1</schedule>

<!-- First day of month at midnight -->
<schedule>0 0 1 * *</schedule>

<!-- Every 15 minutes during business hours (9 AM - 5 PM) -->
<schedule>*/15 9-17 * * 1-5</schedule>

Configuration-Based Schedule

For admin-configurable schedules, use config path instead of hardcoded expression:

etc/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="vendor_module_report_generation" instance="Vendor\Module\Cron\GenerateReport" method="execute">
            <config_path>vendor_module/reports/cron_schedule</config_path>
        </job>
    </group>
</config>

etc/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>
        <vendor_module>
            <reports>
                <cron_schedule>0 1 * * *</cron_schedule><!-- Daily at 1:00 AM -->
            </reports>
        </vendor_module>
    </default>
</config>

etc/adminhtml/system.xml

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Config:etc/system_file.xsd">
    <system>
        <section id="vendor_module">
            <group id="reports">
                <field id="cron_schedule" translate="label" type="text" sortOrder="10" showInDefault="1" showInWebsite="0" showInStore="0">
                    <label>Report Generation Schedule</label>
                    <comment>Cron expression (e.g., "0 1 * * *" for daily at 1 AM)</comment>
                    <!-- Custom backend model for cron expression validation -->
                    <backend_model>Vendor\Module\Model\Config\Backend\CronSchedule</backend_model>
                </field>
            </group>
        </section>
    </system>
</config>

Benefits: - Merchants can adjust schedule without code changes - Backend model validates cron expression syntax - Schedule changes take effect on next cron run

Implementing Cron Job Classes

Basic Cron Job

Cron/CleanupTempData.php

<?php
declare(strict_types=1);

namespace Vendor\Module\Cron;

use Psr\Log\LoggerInterface;
use Vendor\Module\Model\TempDataRepository;

/**
 * Cleanup temporary data older than 30 days
 */
class CleanupTempData
{
    private const RETENTION_DAYS = 30;

    public function __construct(
        private readonly TempDataRepository $tempDataRepository,
        private readonly LoggerInterface $logger
    ) {}

    /**
     * Execute cron job
     *
     * @return void
     */
    public function execute(): void
    {
        $this->logger->info('Starting temporary data cleanup');

        try {
            $deletedCount = $this->tempDataRepository->deleteOlderThan(self::RETENTION_DAYS);

            $this->logger->info('Temporary data cleanup completed', [
                'deleted_records' => $deletedCount,
                'retention_days' => self::RETENTION_DAYS,
            ]);

        } catch (\Exception $e) {
            $this->logger->error('Temporary data cleanup failed', [
                'exception' => $e->getMessage(),
                'trace' => $e->getTraceAsString(),
            ]);

            // Re-throw to mark cron job as failed
            throw $e;
        }
    }
}

Key patterns: - Return type: void (exceptions indicate failure) - Dependency injection: All dependencies via constructor - Logging: Info-level for normal execution, error-level for failures - Error handling: Try-catch with re-throw (ensures job marked as failed)

Cron Job with Locking

Prevent overlapping executions using locks:

Cron/GenerateReport.php

<?php
declare(strict_types=1);

namespace Vendor\Module\Cron;

use Magento\Framework\Lock\LockManagerInterface;
use Psr\Log\LoggerInterface;
use Vendor\Module\Service\ReportGenerator;

/**
 * Generate daily sales report
 */
class GenerateReport
{
    private const LOCK_NAME = 'vendor_module_generate_report';
    private const LOCK_TIMEOUT = 3600; // 1 hour

    public function __construct(
        private readonly LockManagerInterface $lockManager,
        private readonly ReportGenerator $reportGenerator,
        private readonly LoggerInterface $logger
    ) {}

    /**
     * Execute cron job
     *
     * @return void
     */
    public function execute(): void
    {
        // Attempt to acquire lock
        if (!$this->lockManager->lock(self::LOCK_NAME, self::LOCK_TIMEOUT)) {
            $this->logger->warning('Report generation already running, skipping execution');
            return;
        }

        try {
            $this->logger->info('Starting report generation');

            $report = $this->reportGenerator->generate();

            $this->logger->info('Report generation completed', [
                'report_id' => $report->getId(),
                'records_processed' => $report->getRecordCount(),
            ]);

        } catch (\Exception $e) {
            $this->logger->error('Report generation failed', [
                'exception' => $e->getMessage(),
            ]);

            throw $e;

        } finally {
            // Always release lock
            $this->lockManager->unlock(self::LOCK_NAME);
        }
    }
}

Locking strategies:

1. Skip execution if locked (shown above): - Use case: Job can be delayed without issue - Behavior: Skip if already running

2. Wait for lock:

// Wait up to 30 seconds for lock
if (!$this->lockManager->lock(self::LOCK_NAME, self::LOCK_TIMEOUT, 30)) {
    throw new \RuntimeException('Could not acquire lock within 30 seconds');
}

3. Force unlock (dangerous):

// Only use if previous execution crashed without releasing lock
$this->lockManager->unlock(self::LOCK_NAME);
$this->lockManager->lock(self::LOCK_NAME, self::LOCK_TIMEOUT);

Chunked Processing for Large Datasets

Cron/ProcessPendingOrders.php

<?php
declare(strict_types=1);

namespace Vendor\Module\Cron;

use Magento\Framework\App\ResourceConnection;
use Psr\Log\LoggerInterface;
use Vendor\Module\Model\OrderProcessor;

/**
 * Process pending orders in batches
 */
class ProcessPendingOrders
{
    private const BATCH_SIZE = 100;
    private const MAX_EXECUTION_TIME = 300; // 5 minutes

    public function __construct(
        private readonly ResourceConnection $resourceConnection,
        private readonly OrderProcessor $orderProcessor,
        private readonly LoggerInterface $logger
    ) {}

    /**
     * Execute cron job
     *
     * @return void
     */
    public function execute(): void
    {
        $startTime = time();
        $totalProcessed = 0;

        $this->logger->info('Starting pending order processing');

        try {
            while (true) {
                // Check execution time limit
                if (time() - $startTime >= self::MAX_EXECUTION_TIME) {
                    $this->logger->info('Execution time limit reached, stopping processing', [
                        'total_processed' => $totalProcessed,
                        'execution_time' => time() - $startTime,
                    ]);
                    break;
                }

                // Fetch batch of pending orders
                $orders = $this->fetchPendingOrders(self::BATCH_SIZE);

                if (empty($orders)) {
                    $this->logger->info('No more pending orders to process');
                    break;
                }

                // Process batch
                $batchProcessed = $this->processBatch($orders);
                $totalProcessed += $batchProcessed;

                $this->logger->debug('Processed batch of orders', [
                    'batch_size' => count($orders),
                    'processed' => $batchProcessed,
                    'total_processed' => $totalProcessed,
                ]);
            }

            $this->logger->info('Pending order processing completed', [
                'total_processed' => $totalProcessed,
                'execution_time' => time() - $startTime,
            ]);

        } catch (\Exception $e) {
            $this->logger->error('Pending order processing failed', [
                'exception' => $e->getMessage(),
                'total_processed' => $totalProcessed,
            ]);

            throw $e;
        }
    }

    /**
     * Fetch batch of pending orders
     *
     * @param int $limit
     * @return array
     */
    private function fetchPendingOrders(int $limit): array
    {
        $connection = $this->resourceConnection->getConnection();

        $select = $connection->select()
            ->from('sales_order', ['entity_id'])
            ->where('status = ?', 'pending_processing')
            ->order('created_at ASC')
            ->limit($limit);

        return $connection->fetchCol($select);
    }

    /**
     * Process batch of orders
     *
     * @param array $orderIds
     * @return int Number of successfully processed orders
     */
    private function processBatch(array $orderIds): int
    {
        $processed = 0;

        foreach ($orderIds as $orderId) {
            try {
                $this->orderProcessor->process($orderId);
                $processed++;
            } catch (\Exception $e) {
                $this->logger->error('Failed to process order', [
                    'order_id' => $orderId,
                    'exception' => $e->getMessage(),
                ]);
                // Continue processing other orders
            }
        }

        return $processed;
    }
}

Chunking strategies:

Time-based: - Stop after fixed duration (5 minutes) - Ensures cron completes before next run - Remaining work handled by next execution

Count-based: - Process fixed number of items (e.g., 1000) - Predictable execution time - May leave work incomplete if limit reached

Hybrid: - Process until time limit OR item limit reached - Most flexible approach

Long-Running Jobs with Status Tracking

For jobs requiring >1 hour, use status table:

etc/db_schema.xml

<?xml version="1.0"?>
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    <table name="vendor_module_job_status" resource="default" engine="innodb" comment="Cron Job Status Tracking">
        <column xsi:type="int" name="id" unsigned="true" nullable="false" identity="true" comment="ID"/>
        <column xsi:type="varchar" name="job_code" length="255" nullable="false" comment="Job Code"/>
        <column xsi:type="varchar" name="status" length="50" nullable="false" default="pending" comment="Status"/>
        <column xsi:type="int" name="total_items" unsigned="true" nullable="false" default="0" comment="Total Items"/>
        <column xsi:type="int" name="processed_items" unsigned="true" nullable="false" default="0" comment="Processed Items"/>
        <column xsi:type="text" name="error_message" nullable="true" comment="Error Message"/>
        <column xsi:type="timestamp" name="started_at" nullable="true" comment="Started At"/>
        <column xsi:type="timestamp" name="completed_at" nullable="true" comment="Completed At"/>
        <column xsi:type="timestamp" name="created_at" nullable="false" default="CURRENT_TIMESTAMP" comment="Created At"/>

        <constraint xsi:type="primary" referenceId="PRIMARY">
            <column name="id"/>
        </constraint>

        <index referenceId="IDX_JOB_CODE_STATUS" indexType="btree">
            <column name="job_code"/>
            <column name="status"/>
        </index>
    </table>
</schema>

Cron/LongRunningImport.php

<?php
declare(strict_types=1);

namespace Vendor\Module\Cron;

use Magento\Framework\App\ResourceConnection;
use Psr\Log\LoggerInterface;
use Vendor\Module\Model\ImportProcessor;

class LongRunningImport
{
    private const JOB_CODE = 'long_running_import';
    private const BATCH_SIZE = 100;
    private const MAX_EXECUTION_TIME = 840; // 14 minutes (leave buffer before next cron run)

    public function __construct(
        private readonly ResourceConnection $resourceConnection,
        private readonly ImportProcessor $importProcessor,
        private readonly LoggerInterface $logger
    ) {}

    public function execute(): void
    {
        $connection = $this->resourceConnection->getConnection();

        // Find or create job status record
        $jobStatus = $this->getOrCreateJobStatus();

        if ($jobStatus['status'] === 'completed') {
            $this->logger->info('Import already completed, creating new job');
            $jobStatus = $this->createNewJobStatus();
        }

        if ($jobStatus['status'] === 'pending') {
            $this->updateJobStatus($jobStatus['id'], 'running', ['started_at' => date('Y-m-d H:i:s')]);
        }

        $startTime = time();

        try {
            while (true) {
                if (time() - $startTime >= self::MAX_EXECUTION_TIME) {
                    $this->logger->info('Time limit reached, pausing job', [
                        'job_id' => $jobStatus['id'],
                        'processed' => $jobStatus['processed_items'],
                    ]);
                    break;
                }

                $batch = $this->fetchNextBatch($jobStatus['processed_items'], self::BATCH_SIZE);

                if (empty($batch)) {
                    // All items processed
                    $this->updateJobStatus($jobStatus['id'], 'completed', [
                        'completed_at' => date('Y-m-d H:i:s'),
                    ]);

                    $this->logger->info('Import completed', [
                        'total_processed' => $jobStatus['processed_items'],
                    ]);
                    break;
                }

                $this->importProcessor->processBatch($batch);

                // Update progress
                $jobStatus['processed_items'] += count($batch);
                $this->updateJobStatus($jobStatus['id'], 'running', [
                    'processed_items' => $jobStatus['processed_items'],
                ]);
            }

        } catch (\Exception $e) {
            $this->updateJobStatus($jobStatus['id'], 'failed', [
                'error_message' => $e->getMessage(),
            ]);

            $this->logger->error('Import failed', [
                'job_id' => $jobStatus['id'],
                'exception' => $e->getMessage(),
            ]);

            throw $e;
        }
    }

    private function getOrCreateJobStatus(): array
    {
        $connection = $this->resourceConnection->getConnection();

        $select = $connection->select()
            ->from('vendor_module_job_status')
            ->where('job_code = ?', self::JOB_CODE)
            ->order('id DESC')
            ->limit(1);

        $status = $connection->fetchRow($select);

        if ($status) {
            return $status;
        }

        return $this->createNewJobStatus();
    }

    private function createNewJobStatus(): array
    {
        $connection = $this->resourceConnection->getConnection();

        $totalItems = $this->importProcessor->getTotalItemCount();

        $data = [
            'job_code' => self::JOB_CODE,
            'status' => 'pending',
            'total_items' => $totalItems,
            'processed_items' => 0,
        ];

        $connection->insert('vendor_module_job_status', $data);

        $data['id'] = $connection->lastInsertId();

        return $data;
    }

    private function updateJobStatus(int $id, string $status, array $additionalData = []): void
    {
        $connection = $this->resourceConnection->getConnection();

        $data = array_merge(['status' => $status], $additionalData);

        $connection->update(
            'vendor_module_job_status',
            $data,
            ['id = ?' => $id]
        );
    }

    private function fetchNextBatch(int $offset, int $limit): array
    {
        return $this->importProcessor->fetchBatch($offset, $limit);
    }
}

Benefits: - Job survives multiple cron runs - Progress tracked in database - Admin can monitor via UI - Failed jobs can be retried from last position

Cron Group Management

Creating Custom Cron Group

etc/cron_groups.xml

<?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="vendor_heavy_processing">
        <schedule_generate_every>15</schedule_generate_every>
        <schedule_ahead_for>60</schedule_ahead_for>
        <schedule_lifetime>120</schedule_lifetime>
        <history_cleanup_every>10</history_cleanup_every>
        <history_success_lifetime>1440</history_success_lifetime>
        <history_failure_lifetime>10080</history_failure_lifetime>
        <use_separate_process>1</use_separate_process>
    </group>
</config>

Configuration options:

  • schedule_generate_every: Generate schedules every N minutes (default: 1)
  • schedule_ahead_for: Generate schedules N minutes ahead (default: 4)
  • schedule_lifetime: Maximum minutes a schedule can be pending (default: 2)
  • history_cleanup_every: Cleanup history every N minutes (default: 10)
  • history_success_lifetime: Keep successful jobs N minutes (default: 10080 = 7 days)
  • history_failure_lifetime: Keep failed jobs N minutes (default: 10080 = 7 days)
  • use_separate_process: Run group in separate process (0 or 1)

Assigning Jobs to Custom Group

etc/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="vendor_heavy_processing">
        <job name="vendor_module_data_aggregation" instance="Vendor\Module\Cron\DataAggregation" method="execute">
            <schedule>0 */6 * * *</schedule><!-- Every 6 hours -->
        </job>
        <job name="vendor_module_report_generation" instance="Vendor\Module\Cron\ReportGeneration" method="execute">
            <schedule>0 3 * * *</schedule><!-- Daily at 3 AM -->
        </job>
    </group>
</config>

Running Specific Cron Group

# Run only specific group
bin/magento cron:run --group="vendor_heavy_processing"

# Run all groups
bin/magento cron:run

# Run in background
bin/magento cron:run --group="vendor_heavy_processing" > /dev/null 2>&1 &

Production setup (separate server for heavy jobs):

# Server 1: Run default and index groups
* * * * * /usr/bin/php /var/www/magento/bin/magento cron:run --group=default
* * * * * /usr/bin/php /var/www/magento/bin/magento cron:run --group=index

# Server 2: Run heavy processing group
* * * * * /usr/bin/php /var/www/magento/bin/magento cron:run --group=vendor_heavy_processing

Error Handling and Logging

Comprehensive Error Handling

Cron/RobustExample.php

<?php
declare(strict_types=1);

namespace Vendor\Module\Cron;

use Magento\Framework\Exception\LocalizedException;
use Psr\Log\LoggerInterface;
use Vendor\Module\Model\DataProcessor;

class RobustExample
{
    public function __construct(
        private readonly DataProcessor $dataProcessor,
        private readonly LoggerInterface $logger
    ) {}

    public function execute(): void
    {
        $context = ['job' => 'robust_example'];

        $this->logger->info('Job started', $context);

        try {
            $result = $this->dataProcessor->process();

            if ($result->hasErrors()) {
                $this->handlePartialFailure($result, $context);
                return;
            }

            $this->logger->info('Job completed successfully', array_merge($context, [
                'records_processed' => $result->getProcessedCount(),
            ]));

        } catch (LocalizedException $e) {
            // Business logic exception (expected, recoverable)
            $this->logger->warning('Job failed with expected error', array_merge($context, [
                'error' => $e->getMessage(),
            ]));

            // Don't re-throw: Job completes with warning, won't retry

        } catch (\InvalidArgumentException $e) {
            // Configuration/input error (permanent)
            $this->logger->error('Job failed due to configuration error', array_merge($context, [
                'error' => $e->getMessage(),
                'trace' => $e->getTraceAsString(),
            ]));

            // Don't re-throw: Fix configuration, retrying won't help

        } catch (\Exception $e) {
            // Unexpected exception (infrastructure, database, etc.)
            $this->logger->critical('Job failed with unexpected error', array_merge($context, [
                'error' => $e->getMessage(),
                'trace' => $e->getTraceAsString(),
            ]));

            // Re-throw: Mark job as failed, enable retry mechanism
            throw $e;
        }
    }

    private function handlePartialFailure($result, array $context): void
    {
        $this->logger->warning('Job completed with partial failures', array_merge($context, [
            'records_processed' => $result->getProcessedCount(),
            'records_failed' => $result->getFailedCount(),
            'errors' => $result->getErrors(),
        ]));
    }
}

Error handling strategy:

Exception Type Log Level Re-throw? Reason
Expected business errors WARNING No Normal operation, no retry needed
Configuration errors ERROR No Manual fix required
Infrastructure errors CRITICAL Yes Temporary, may succeed on retry

Custom Logger for Cron Jobs

etc/di.xml

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">

    <!-- Custom logger for cron jobs -->
    <virtualType name="VendorCronLogger" type="Magento\Framework\Logger\Monolog">
        <arguments>
            <argument name="name" xsi:type="string">vendorCron</argument>
            <argument name="handlers" xsi:type="array">
                <item name="system" xsi:type="object">VendorCronLogHandler</item>
            </argument>
        </arguments>
    </virtualType>

    <virtualType name="VendorCronLogHandler" type="Magento\Framework\Logger\Handler\Base">
        <arguments>
            <argument name="fileName" xsi:type="string">/var/log/vendor_cron.log</argument>
        </arguments>
    </virtualType>

    <!-- Inject custom logger into cron job -->
    <type name="Vendor\Module\Cron\CleanupTempData">
        <arguments>
            <argument name="logger" xsi:type="object">VendorCronLogger</argument>
        </arguments>
    </type>

</config>

Benefits: - Separate log file for cron jobs - Easier debugging and monitoring - Different retention policies per log

Testing Cron Jobs

Manual Testing

1. Generate schedule entries:

# Generate schedules for all groups
bin/magento cron:run --group=default

# Check schedules created
mysql> SELECT job_code, status, scheduled_at, executed_at FROM cron_schedule WHERE job_code = 'vendor_module_cleanup_temp_data' ORDER BY scheduled_at DESC LIMIT 10;

2. Run specific job:

# Trigger all pending jobs
bin/magento cron:run

# Or run specific group
bin/magento cron:run --group=default

3. Check execution:

# View job status
mysql> SELECT job_code, status, messages, executed_at FROM cron_schedule WHERE job_code = 'vendor_module_cleanup_temp_data' ORDER BY schedule_id DESC LIMIT 1;

# View logs
tail -f var/log/system.log | grep vendor_module_cleanup

Unit Testing

Test/Unit/Cron/CleanupTempDataTest.php

<?php
declare(strict_types=1);

namespace Vendor\Module\Test\Unit\Cron;

use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Vendor\Module\Cron\CleanupTempData;
use Vendor\Module\Model\TempDataRepository;

class CleanupTempDataTest extends TestCase
{
    private CleanupTempData $cronJob;
    private TempDataRepository $tempDataRepository;
    private LoggerInterface $logger;

    protected function setUp(): void
    {
        $this->tempDataRepository = $this->createMock(TempDataRepository::class);
        $this->logger = $this->createMock(LoggerInterface::class);

        $this->cronJob = new CleanupTempData(
            $this->tempDataRepository,
            $this->logger
        );
    }

    public function testExecuteSuccessfully(): void
    {
        $this->tempDataRepository
            ->expects($this->once())
            ->method('deleteOlderThan')
            ->with(30)
            ->willReturn(150);

        $this->logger
            ->expects($this->exactly(2))
            ->method('info');

        $this->cronJob->execute();
    }

    public function testExecuteHandlesException(): void
    {
        $this->tempDataRepository
            ->expects($this->once())
            ->method('deleteOlderThan')
            ->willThrowException(new \RuntimeException('Database error'));

        $this->logger
            ->expects($this->once())
            ->method('error');

        $this->expectException(\RuntimeException::class);

        $this->cronJob->execute();
    }
}

Integration Testing

Test/Integration/Cron/CleanupTempDataTest.php

<?php
declare(strict_types=1);

namespace Vendor\Module\Test\Integration\Cron;

use Magento\TestFramework\Helper\Bootstrap;
use PHPUnit\Framework\TestCase;
use Vendor\Module\Cron\CleanupTempData;

class CleanupTempDataTest extends TestCase
{
    private CleanupTempData $cronJob;

    protected function setUp(): void
    {
        $objectManager = Bootstrap::getObjectManager();
        $this->cronJob = $objectManager->get(CleanupTempData::class);
    }

    public function testExecuteDeletesOldRecords(): void
    {
        // Create test data
        $this->createTestData();

        // Execute cron
        $this->cronJob->execute();

        // Assert old records deleted
        $this->assertOldRecordsDeleted();
    }

    private function createTestData(): void
    {
        // Insert test records with old timestamps
    }

    private function assertOldRecordsDeleted(): void
    {
        // Verify cleanup occurred
    }
}

Testing Cron Schedule Generation

public function testCronScheduleIsGenerated(): void
{
    $objectManager = Bootstrap::getObjectManager();

    /** @var \Magento\Cron\Model\Schedule $schedule */
    $schedule = $objectManager->create(\Magento\Cron\Model\Schedule::class);

    // Generate schedules
    $schedule->generate();

    // Check schedule exists
    $scheduleCollection = $objectManager->create(\Magento\Cron\Model\ResourceModel\Schedule\Collection::class);
    $scheduleCollection->addFieldToFilter('job_code', 'vendor_module_cleanup_temp_data');

    $this->assertGreaterThan(0, $scheduleCollection->getSize());
}

Production Deployment

Server Crontab Setup

Option 1: All groups in one cron entry:

# Run every minute
* * * * * /usr/bin/php /var/www/magento/bin/magento cron:run 2>&1 | grep -v "Ran jobs by schedule" >> /var/www/magento/var/log/magento.cron.log

Option 2: Separate entries per group:

# Default group
* * * * * /usr/bin/php /var/www/magento/bin/magento cron:run --group=default >> /var/www/magento/var/log/cron_default.log 2>&1

# Index group
* * * * * /usr/bin/php /var/www/magento/bin/magento cron:run --group=index >> /var/www/magento/var/log/cron_index.log 2>&1

# Consumers group (for message queues)
* * * * * /usr/bin/php /var/www/magento/bin/magento cron:run --group=consumers >> /var/www/magento/var/log/cron_consumers.log 2>&1

Option 3: Supervisor (recommended for production):

/etc/supervisor/conf.d/magento-cron.conf

[program:magento-cron]
command=/usr/bin/php /var/www/magento/bin/magento cron:run
autostart=true
autorestart=true
user=www-data
numprocs=1
redirect_stderr=true
stdout_logfile=/var/www/magento/var/log/cron.log

Monitoring Cron Health

Check cron is running:

# Last execution time
mysql> SELECT MAX(executed_at) FROM cron_schedule WHERE status = 'success';

# Pending jobs
mysql> SELECT job_code, COUNT(*) FROM cron_schedule WHERE status = 'pending' GROUP BY job_code;

# Failed jobs (last hour)
mysql> SELECT job_code, messages FROM cron_schedule WHERE status = 'error' AND executed_at > DATE_SUB(NOW(), INTERVAL 1 HOUR);

Monitoring script:

bin/check-cron-health.php

#!/usr/bin/env php
<?php

require __DIR__ . '/../app/bootstrap.php';

$bootstrap = \Magento\Framework\App\Bootstrap::create(BP, $_SERVER);
$objectManager = $bootstrap->getObjectManager();

$resource = $objectManager->get(\Magento\Framework\App\ResourceConnection::class);
$connection = $resource->getConnection();

// Check last successful execution
$lastSuccess = $connection->fetchOne(
    "SELECT MAX(executed_at) FROM cron_schedule WHERE status = 'success'"
);

if (!$lastSuccess || strtotime($lastSuccess) < time() - 300) {
    echo "CRITICAL: No successful cron jobs in last 5 minutes\n";
    exit(2);
}

// Check for stuck jobs
$stuck = $connection->fetchOne(
    "SELECT COUNT(*) FROM cron_schedule WHERE status = 'running' AND executed_at < DATE_SUB(NOW(), INTERVAL 1 HOUR)"
);

if ($stuck > 0) {
    echo "WARNING: $stuck jobs stuck in 'running' state\n";
    exit(1);
}

// Check for high failure rate
$recentTotal = $connection->fetchOne(
    "SELECT COUNT(*) FROM cron_schedule WHERE executed_at > DATE_SUB(NOW(), INTERVAL 1 HOUR)"
);

$recentFailed = $connection->fetchOne(
    "SELECT COUNT(*) FROM cron_schedule WHERE status = 'error' AND executed_at > DATE_SUB(NOW(), INTERVAL 1 HOUR)"
);

if ($recentTotal > 0 && ($recentFailed / $recentTotal) > 0.1) {
    echo "WARNING: High failure rate: $recentFailed / $recentTotal in last hour\n";
    exit(1);
}

echo "OK: Cron system healthy\n";
exit(0);

Integrate with monitoring system:

# Nagios, Icinga, etc.
*/5 * * * * /var/www/magento/bin/check-cron-health.php

Assumptions

  • Target versions: Magento 2.4.7+, PHP 8.2+
  • Deployment: Linux server with cron daemon installed
  • Infrastructure: Adequate resources for concurrent cron execution
  • Monitoring: Logging and alerting infrastructure in place

Why This Approach

crontab.xml: Centralized, version-controlled cron configuration

Cron groups: Resource isolation, enables scaling across servers

Locking: Prevents race conditions and duplicate processing

Chunked processing: Memory-efficient, respects execution time limits

Status tracking: Enables long-running jobs spanning multiple executions

Custom loggers: Separate cron logs for easier debugging

Security Impact

Authorization: Cron jobs run as PHP user, inherit filesystem permissions

CLI access: Cron execution requires PHP CLI access (secure server access)

Sensitive data: Avoid logging credentials, PII, or payment data

Resource limits: PHP memory_limit and max_execution_time apply to cron jobs

Performance Impact

System resources: - Each cron group = separate PHP process - Peak load: 3-5 groups × 1-2 minutes execution = high CPU/memory spikes - Stagger heavy jobs (run at different times)

Database impact: - cron_schedule table grows continuously (cleanup via history settings) - Concurrent cron jobs may lock database tables - Use row-level locking (InnoDB) to minimize contention

Scalability: - Single server: 10-20 concurrent jobs (depends on resources) - Multi-server: Run groups on separate servers - Message queues: Offload heavy processing to consumers

Backward Compatibility

API stability: - Cron configuration XML schema stable across Magento 2.4.x - cron:run command stable

Database schema: - cron_schedule table structure stable - Job status tracking via custom tables (safe)

Upgrade path: - Magento 2.4.7 → 2.4.8: No breaking changes expected - Magento 2.4.x → 2.5.0: Monitor deprecation notices

Tests to Add

Unit tests: - Cron job execute method logic - Error handling paths - Locking behavior (mock LockManager)

Integration tests: - Cron schedule generation - Job execution with real dependencies - Cleanup of old schedules

Functional tests: - End-to-end job execution - Long-running job status tracking - Failure recovery

Documentation to Update

Developer documentation: - README.md: Cron architecture overview - CRON.md: Custom cron job development guide - MONITORING.md: Cron health monitoring setup

Operations documentation: - Server crontab configuration - Supervisor setup for production - Troubleshooting stuck jobs - Log rotation and retention policies