Skip to content

Cron jobs

Maho uses a cron system to run scheduled background tasks - things like sending queued emails, cleaning up old logs, generating sitemaps, and applying catalog price rules.

Defining cron jobs

v26.5+

Since v26.5, cron jobs are defined using PHP attributes directly on the method. Previously they were configured in XML - see Migrating from XML if you're upgrading an existing module.

Place the #[Maho\Config\CronJob] attribute on a public method to register it as a cron job:

class Maho_AdminActivityLog_Model_Observer
{
    #[Maho\Config\CronJob('adminactivitylog_clean_old_logs', schedule: '0 2 * * *')]
    public function cleanOldLogs(): void
    {
        // Runs daily at 2:00 AM
    }
}

After adding or changing any cron job attribute, run:

composer dump-autoload

This compiles all attributes into vendor/composer/maho_attributes.php, which is what the runtime reads.

Parameters

Parameter Type Default Description
id string (required) Job identifier (e.g. 'sitemap_generate', 'core_clean_cache')
schedule ?string null Cron expression (e.g. '0 2 * * *', '*/5 * * * *')
configPath ?string null Config path for admin-configurable schedule

Fixed schedule

Use the schedule parameter with a standard cron expression:

#[Maho\Config\CronJob('giftcard_process_scheduled_emails', schedule: '*/5 * * * *')]
public function processScheduledEmails(): void { }

Admin-configurable schedule

Use configPath instead of schedule when the cron expression should be configurable from the admin panel:

#[Maho\Config\CronJob('customersegmentation_refresh_segments', configPath: 'customer/customer_segments/cron_schedule')]
public function refreshSegments(): void { }

Note

Provide either schedule or configPath, not both. If neither is set, the job can only be triggered manually via ./maho cron:run <job_id>.

Individual cron job failures are caught and recorded without stopping the rest of the cron run.

Deploy to production

When you deploy a Maho project in production, you need to set up cron this way:

*/5 * * * * cd /var/www/mahoproject; php ./maho cron:run default >/dev/null 2>&1
*/5 * * * * cd /var/www/mahoproject; php ./maho cron:run always >/dev/null 2>&1

Each cron:run invocation takes a lock and exits immediately if a previous run of the same group (default, always, ...) is still going, so overlapping runs can't pile up. You don't need to wrap these lines in flock(1) or a ps guard yourself.

Locking across multiple servers

v26.7+

The configurable lock backend described below is available since v26.7.

Maho serializes background work (cron dispatch, reindexing, order placement, ...) with named locks. The backend is configured in app/etc/local.xml and defaults to file:

<global>
    <!-- ... -->
    <lock>
        <backend>file</backend> <!-- default; omit the whole block to use it -->
    </lock>
</global>
  • file (default): kernel flock in var/locks, released the instant the holding process exits or crashes. Correct for single-server setups.
  • db: the database server's advisory locks (MySQL GET_LOCK, PostgreSQL advisory locks). Use this when multiple application servers share one database so they don't both run the same job at once; file locks are local to each machine and won't see each other.

Warning

Don't use the db backend on Galera-style clusters (no advisory lock support) or on SQLite (only emulated via an expiring lock table). Advisory locks also belong to the DB session: if the connection drops mid-run (e.g. MySQL wait_timeout on a long job), the server releases the lock while PHP keeps running.

Check job status

Using Maho's CLI tool you can get info about jobs and their status with these two commands:

./maho cron:list
+----------------------------------------------+-----------------------------------------------------------+--------------+
| event                                        | model::method                                             | schedule     |
+----------------------------------------------+-----------------------------------------------------------+--------------+
| aggregate_reports_report_product_viewed_data | reports/observer::aggregateReportsReportProductViewedData | 0 0 * * *    |
| aggregate_sales_report_bestsellers_data      | sales/observer::aggregateSalesReportBestsellersData       | 0 0 * * *    |
| aggregate_sales_report_coupons_data          | salesrule/observer::aggregateSalesReportCouponsData       | 0 0 * * *    |
| aggregate_sales_report_invoiced_data         | sales/observer::aggregateSalesReportInvoicedData          | 0 0 * * *    |
| aggregate_sales_report_order_data            | sales/observer::aggregateSalesReportOrderData             | 0 0 * * *    |
| aggregate_sales_report_refunded_data         | sales/observer::aggregateSalesReportRefundedData          | 0 0 * * *    |
| aggregate_sales_report_shipment_data         | sales/observer::aggregateSalesReportShipmentData          | 0 0 * * *    |
| aggregate_sales_report_tax_data              | tax/observer::aggregateSalesReportTaxData                 | 0 0 * * *    |
| api_session_cleanup                          | api/cron::cleanOldSessions                                | 0 35 * * *   |
| catalogrule_apply_all                        | catalogrule/observer::dailyCatalogUpdate                  | 0 1 * * *    |
| catalog_product_alert                        | productalert/observer::process                            |              |
| catalog_product_index_price_reindex_all      | catalog/observer::reindexProductPrices                    | 0 2 * * *    |
| core_clean_cache                             | core/observer::cleanCache                                 | 30 2 * * *   |
| core_email_queue_clean_up                    | core/email_queue::cleanQueue                              | 0 0 * * *    |
| core_email_queue_send_all                    | core/email_queue::send                                    | */1 * * * *  |
| currency_rates_update                        | directory/observer::scheduledUpdateCurrencyRates          |              |
| customer_flowpassword                        | customer/observer::deleteCustomerFlowPassword             | 0 0 1 * *    |
| index_clean_events                           | index/observer::cleanOutdatedEvents                       | 30 */4 * * * |
| log_clean                                    | log/cron::logClean                                        |              |
| newsletter_send_all                          | newsletter/observer::scheduledSend                        | */5 * * * *  |
| paypal_fetch_settlement_reports              | paypal/observer::fetchReports                             |              |
| persistent_clear_expired                     | persistent/observer::clearExpiredCronJob                  | 0 0 * * *    |
| sales_clean_quotes                           | sales/observer::cleanExpiredQuotes                        | 0 0 * * *    |
| sitemap_generate                             | sitemap/observer::scheduledGenerateSitemaps               |              |
+----------------------------------------------+-----------------------------------------------------------+--------------+
./maho cron:history
+-------------+---------------------------+---------+----------+---------------------+---------------------+-------------+-------------+
| schedule_id | job_code                  | status  | messages | messages            | scheduled_at        | executed_at | finished_at |
+-------------+---------------------------+---------+----------+---------------------+---------------------+-------------+-------------+
| 167         | core_email_queue_send_all | pending |          | 2024-08-10 23:09:41 | 2024-08-10 23:09:00 |             |             |
| 168         | core_email_queue_send_all | pending |          | 2024-08-10 23:09:41 | 2024-08-10 23:10:00 |             |             |
| ...         | ...                       | ...     | ...      | ...                 | ...                 | ...         | ...         |
+-------------+---------------------------+---------+----------+---------------------+---------------------+-------------+-------------+

Test locally

When developing a Maho project locally, you don't need to set up cron, but you may want to run a specific cron job.

This can be done passing the job_code you want to execute to ./maho cron:run, like:

./maho cron:run core_email_queue_send_all
core_email_queue_send_all executed successfully

Note

If there's a record in the cron_schedule table for the specified job_code with status of pending, that record will be "burnt" otherwise no record will be created but the job will be executed anyway.

Migrating from XML

If you're upgrading a module that used XML-based cron configuration:

Before (config.xml):

<config>
    <crontab>
        <jobs>
            <newsletter_send_all>
                <schedule><cron_expr>*/5 * * * *</cron_expr></schedule>
                <run><model>newsletter/observer::scheduledSend</model></run>
            </newsletter_send_all>
        </jobs>
    </crontab>
</config>

After (PHP attribute):

#[Maho\Config\CronJob('newsletter_send_all', schedule: '*/5 * * * *')]
public function scheduledSend(Mage_Cron_Model_Schedule $schedule): void { }

For config-driven schedules:

<!-- Before -->
<schedule><config_path>crontab/jobs/catalog_product_alert/schedule/cron_expr</config_path></schedule>
// After
#[Maho\Config\CronJob('catalog_product_alert', configPath: 'crontab/jobs/catalog_product_alert/schedule/cron_expr')]

Remove the corresponding XML blocks from config.xml after migrating; both sources are read at runtime, and duplicates will cause issues.

Automated migration

Two commands cover this end-to-end:

./maho health-check                    # detect remaining <crontab><jobs> blocks
./maho legacy:migrate-cron --dry-run   # preview the rewrite
./maho legacy:migrate-cron             # apply

legacy:migrate-cron scans app/code/local and app/code/community, parses each <run><model>alias::method</model>, resolves the alias, inserts #[Maho\Config\CronJob] above the target method, and removes the migrated <crontab><jobs> block. It reads <schedule><cron_expr> (emitted as schedule:) or <schedule><config_path> (emitted as configPath:). Run composer dump-autoload (or click Recompile PHP Attributes on the cache page) after applying. See Automated migration in routing.md for the full list of migration commands.