---
topic: cron
title: Adding and deleting cron jobs
summary: tcron and tjobqueue via CronController — which job types exist, why an unknown type fails silently, and why most jobs you can add cannot be deleted.
appliesTo:
  - cron.job.add
  - cron.job.delete
derivedFrom:
  - includes/src/Router/Controller/Backend/CronController.php:60-75
  - includes/src/Router/Controller/Backend/CronController.php:135-173
  - includes/src/Router/Controller/Backend/CronController.php:178-191
  - includes/src/Cron/CronService.php:35-47
  - includes/src/Cron/CronService.php:85-92
  - includes/src/Cron/CronRepository.php:38-49
  - includes/src/Cron/JobQueueRepository.php:36-47
  - includes/src/Mapper/JobTypeToJob.php:36-65
  - includes/src/Cron/Type.php:16-48
---

## Domain model

Two tables. `tcron` is the schedule — `cronID`, `jobType`, `frequency` (hours), `name`, `startDate`,
`startTime`, `nextStart`, and optionally `tableName`/`foreignKey`/`foreignKeyID` for jobs bound to a
row. `tjobqueue` is the runtime: one row per run the cron gets picked up for, carrying its own
`cronID` back-reference. Deleting a schedule deletes its queued runs; nothing else joins them.

`jobType` is a plain string, one of the constants in `includes/src/Cron/Type.php:16-48`
(`imagecache`, `statusemail`, `topsellerupdate`, `generateXSelling`, `redirectCleanup`,
`visitorCount`, `exportformat`, `newsletter`, `dataprotection`, `store`, `licensecheck`,
`sendMailQueue`, `telemetry`, `guaranteePdfCleanup`, `plugin`, `tskundenbewertung`, `clearcache`).
Two different lists narrow that set and they do **not** agree with each other:

- **What the backend offers to add** — `CronController::getAvailableCronJobs()` (`:178-191`):
  `imagecache`, `statusemail`, `topsellerupdate`, `generateXSelling`, `redirectCleanup`,
  `visitorCount`, plus whatever a plugin appends on `Event::GET_AVAILABLE_CRONJOBS` (`:188`).
- **What can actually be inserted** — `JobTypeToJob::map()` (`:36-65`), a `match` over 14 types plus
  an `Event::MAP_CRONJOB_TYPE` escape hatch for plugins. `plugin`, `clearcache` and
  `tskundenbewertung` are Type constants that `map()` does **not** handle, so they throw.
- **What can be deleted** — everything except `CronService::getPermanentJobTypes()`
  (`CronService.php:35-47`): `licensecheck`, `sendMailQueue`, `dataprotection`, `imagecache`,
  `topsellerupdate`, `generateXSelling`, `redirectCleanup`, `telemetry`, `visitorCount`.

Read those three lists against each other before you plan anything: **five of the six types the
backend offers to add are permanent and therefore undeletable.** `statusemail` is the only overlap
that round-trips.

## Reads first

1. `run_sql_read "SELECT cronID, jobType, name, frequency, startDate, startTime, nextStart FROM
   tcron ORDER BY cronID"` — the existing schedule, and the source of the `cronID` that
   `cron.job.delete` takes.
2. `run_sql_read "SELECT cronID, jobType, tasksExecuted, isRunning, lastStart FROM tjobqueue"` — what
   is queued or mid-run. A job that is running is not protected from deletion.
3. For a `statusemail` job: `run_sql_read "SELECT id, nInterval, nAktiv FROM tstatusemail"`. This is
   the row set the insert fans out over, and the source of that job's frequency.

## Walkthrough — adding a job

1. Pick a `jobType`. Use `getAvailableCronJobs()` semantics as the *supported* set, not `Type.php` —
   the constants that `map()` rejects are indistinguishable from a typo at call time. If a plugin
   contributes a type, only that plugin's listener knows the string; there is no table to read it
   from.
2. Set `frequency` (**hours**, cast with `(int)` at `:165`), `time` (`HH:MM`, padded to `HH:MM:SS`
   at `:144` when it is exactly 5 characters) and `date` (parsed by `DateTimeImmutable` at `:143`).
   `date` and `time` are both dereferenced without `??`. `nextStart` is composed from the *date* part
   of `date` plus `time` (`:170`) — the time component of `date` is discarded for scheduling but kept
   verbatim in `startDate` (`:169`).
3. Send `add-cron=1` and invoke.
4. Check `verify.ok`, not just `ok`.

## Walkthrough — deleting a job

1. Read the `cronID` (read 1) **and** its `jobType`.
2. Check the `jobType` against the permanent list above. If it is on it, stop — the call will succeed
   and change nothing.
3. Invoke with `confirm: true` (the entry is `Destructive`). `CronService::delete()` runs two
   statements: `DELETE FROM tcron WHERE cronID IN (:ids) AND jobType NOT IN (:jobTypes)`
   (`CronRepository.php:38-49`) and the same shape against `tjobqueue`
   (`JobQueueRepository.php:36-47`). Both carry the permanent-type exclusion, so a permanent job
   keeps both its schedule and its queue entries.
4. Verify with read 1.

## Three most common failures

1. **An unknown job type is swallowed whole.** `addQueueEntry()` catches the
   `InvalidArgumentException` from `map()` and returns `-1` (`:138-142`) — no insert, no alert, no
   exception reaching the caller, and the page renders as if it worked. The `verify:` clause on
   `cron.job.add` is the only thing that tells you. Any typo, any plugin type whose plugin is not
   installed, and any of `plugin` / `clearcache` / `tskundenbewertung` lands here.

2. **Assuming one call inserts one row.** `statusemail` is special-cased (`:145-162`): it inserts one
   `tcron` row **per active `tstatusemail` row**, each with `frequency = nInterval * 24` (`:149`) —
   so `nInterval` is days, the posted `frequency` is ignored entirely for this type, and a shop with
   six active status mails gets six cron jobs from one call. With zero active rows it inserts nothing
   and returns 0, which reads exactly like the `-1` failure unless you check `verify`.

3. **Deleting a permanent job and believing it.** `DELETE … AND jobType NOT IN (…)` affects zero rows
   and `deleteCron()` returns `false`, but the controller only counts (`:69`) and re-renders. Five of
   the six addable types are permanent; this is the common case, not the edge case.

## What you cannot do here

**You cannot edit a job.** There is no update branch — delete and re-add, and only for a type that is
deletable in the first place.

**You cannot run a job now.** `CronService::startAsap()` exists (`CronService.php:97-100`) but no
catalogued action reaches it, and it is not the same as executing the job — it only moves
`nextStart`.

**You cannot delete more than one job per call.** The controller passes exactly one id
(`[Request::pInt('delete')]`, `:69`), and the repository binds `:ids` as a single comma-joined string
into `IN (:ids)` (`CronRepository.php:40-45`), which does not expand into a list. Delete one at a
time.

**You cannot choose the job's name.** Non-`statusemail` inserts get `'manuell@' . date(...)`
(`:167`); `statusemail` inserts get `'statusemail'` (`:151`). It is a label, not a key.

**Nothing here runs the cron.** `tcron` is a schedule read by an external trigger (a system cron or
the shop's own request-driven runner). Adding a row changes when work *would* happen, not whether
anything is executing.
