Concrete CMS scheduling fix
August 23, 2025 · AI Conversations
Class 'ConcreteCoreEntityAutomationTriggerOnScheduleTrigger' does not exist
That error tells me you’re trying to use `OnScheduleTrigger`, but in **Concrete CMS 9** the automation API classes have changed — there is **no**
`ConcreteCoreEntityAutomationTriggerOnScheduleTrigger`.
Here’s the correct mapping for scheduling tasks in Concrete CMS 9:
– **Tasks** live under `ConcreteCoreCommandTask`
– **Schedules** live under `ConcreteCoreAutomationSchedule`
– The default “Run on Schedule” trigger entity is
`ConcreteCoreEntityAutomationSchedule`.
So instead of `OnScheduleTrigger`, you should use the **Schedule entity** and **ScheduleFactory**.
Example for registering a scheduled task in your package’s `on_start` or `install()`:
use ConcreteCoreAutomationTaskTaskFactory;
use ConcreteCoreAutomationScheduleScheduleFactory;
use ConcreteCoreEntityAutomationSchedule;
public function on_start()
{
$app = $this->app;
// Get the task factory
$taskFactory = $app->make(TaskFactory::class);
// Example: find your task (registered via tasks.xml or manually)
$task = $taskFactory->getByHandle('generate_merchant_feed');
if ($task) {
// Get schedule factory
$scheduleFactory = $app->make(ScheduleFactory::class);
// Create or update a schedule for this task
$schedule = $scheduleFactory->createFromTask($task);
// Example: run every day at midnight
$schedule->setCronExpression('0 0 * * *');
$schedule->setIsEnabled(true);
$em = $app->make('database/orm')->entityManager();
$em->persist($schedule);
$em->flush();
}
}
### Key Points:
– Replace `OnScheduleTrigger` with `ConcreteCoreEntityAutomationSchedule`.
– Use `ScheduleFactory` to create schedules tied to tasks.
– Schedules will then show up in **Dashboard → System & Settings → Automation → Schedules**.
👉 Question: Do you want me to show you how to **define the task itself** (with `tasks.xml` + Task Controller), or just how to attach your existing task to the schedule UI?
You don’t need to build a separate **`FeedScheduler`** class anymore.
Concrete CMS 9 already gives you:
– **TaskFactory** → to register/find your task (from `tasks.xml` or code).
– **ScheduleFactory** → to create schedules for that task.
– **Schedule entity** → which Concrete uses to store the cron expression, enabled/disabled state, etc.
So instead of maintaining a custom `FeedScheduler`, you just:
1. Define your task (e.g. `generate_merchant_feed`) in `tasks.xml` and its controller.
2. Use `ScheduleFactory` in your package (install or on_start) to create the schedule.
3. Then it automatically shows up in **Dashboard → System & Settings → Automation → Schedules**.
👉 In other words:
– **Old Concrete (8.x)** → you’d create a custom scheduler/trigger class.
– **Concrete 9+** → everything is standardized with `Schedule`, so you don’t reinvent it.
—
Do you want me to write you a **full minimal working example** (task + schedule) so you can drop it in your package and see it appear in the dashboard?
SO give me code here
– defines a custom task (`generate_merchant_feed`)
– wires a controller + command + handler
– registers the task in `tasks.xml`
– **creates a schedule in PHP** (cron-style: daily at 00:00)
I’m using the **official v9 task APIs**: `ConcreteCoreCommandTask*` for tasks, and `ConcreteCoreCommandSchedulerScheduler` to create scheduled tasks programmatically. (Docs for tasks & the scheduler are here: citeturn12view0turn14view0turn10view0turn13search0)
—
### 1) `packages/merchant_feed/controller.php` (package controller)
<?php
namespace ConcretePackageMerchantFeed;
use ConcreteCorePackagePackage;
use ConcreteCoreCommandTaskManager as TaskManager;
use ConcreteCoreCommandTaskTaskService;
use ConcreteCoreCommandTaskInputInputFactory;
use ConcreteCoreCommandSchedulerScheduler;
use ConcretePackageMerchantFeedCommandTaskControllerGenerateMerchantFeedController;
defined('C5_EXECUTE') or die('Access Denied.');
class Controller extends Package
{
protected $pkgHandle = 'merchant_feed';
protected $appVersionRequired = '9.0.0';
protected $pkgVersion = '1.0.0';
public function getPackageName()
{
return t('Merchant Feed');
}
public function getPackageDescription()
{
return t('Generates a merchant product feed on a schedule.');
}
// PSR-4 autoload for your src/ folder
public function getPackageAutoloaderRegistries()
{
return [
'src' => 'ConcretePackageMerchantFeed',
];
}
public function install()
{
$pkg = parent::install();
// Install task via content XML
$this->installContentFile('tasks.xml');
// After task exists, create a schedule (e.g., every day at midnight)
$this->createDailySchedule('generate_merchant_feed', '0 0 * * *');
}
public function upgrade()
{
parent::upgrade();
$this->installContentFile('tasks.xml');
}
public function on_start()
{
// Register the task controller with the Task Manager
$manager = $this->app->make(TaskManager::class);
$manager->extend('generate_merchant_feed', function () {
return new GenerateMerchantFeedController();
});
}
/**
* Helper: create a scheduled task (if not already scheduled)
*/
protected function createDailySchedule(string $handle, string $cron)
{
$app = $this->app;
/** @var TaskService $taskService */
$taskService = $app->make(TaskService::class);
$task = $taskService->getByHandle($handle);
if (!$task) {
return; // task not installed yet
}
/** @var InputFactory $inputFactory */
$inputFactory = $app->make(InputFactory::class);
$input = $inputFactory->createFromArray([]); // add task inputs here if you have any
/** @var Scheduler $scheduler */
$scheduler = $app->make(Scheduler::class);
// Creates (or updates) a scheduled task entry tied to this Task + Input + CRON
$scheduler->createScheduledTask($task, $input, $cron);
}
}
> Why these classes?
> – **TaskService**: find tasks by handle (core v9 API). citeturn3view0
> – **InputFactory**: build a `InputInterface` for the task run. citeturn11search5
> – **Scheduler**: programmatically create a schedule (UI uses same system). citeturn10view0
—
### 2) `packages/merchant_feed/tasks.xml` (register the task)
<?xml version="1.0" encoding="UTF-8"?>
<concrete5-cif version="1.0">
<tasks>
<!-- Handle must match what you extend() in on_start -->
<task handle="generate_merchant_feed" package="merchant_feed"/>
</tasks>
<!-- Optional: put your task in a task set -->
<tasksets>
<taskset handle="maintenance">
<task handle="generate_merchant_feed"/>
</taskset>
</tasksets>
</concrete5-cif>
(Tasks XML pattern per docs. citeturn12view0)
—
### 3) Task Controller
`packages/merchant_feed/src/Command/Task/Controller/GenerateMerchantFeedController.php`
<?php
namespace ConcretePackageMerchantFeedCommandTaskController;
use ConcreteCoreCommandTaskControllerAbstractController;
use ConcreteCoreCommandTaskInputInputInterface;
use ConcreteCoreCommandTaskRunnerCommandTaskRunner;
use ConcreteCoreCommandTaskRunnerTaskRunnerInterface;
use ConcreteCoreCommandTaskTaskInterface;
use ConcretePackageMerchantFeedFeedCommandGenerateMerchantFeedCommand;
defined('C5_EXECUTE') or die("Access Denied.");
class GenerateMerchantFeedController extends AbstractController
{
public function getName(): string
{
return t('Generate Merchant Feed');
}
public function getDescription(): string
{
return t('Builds and stores the merchant product feed.');
}
public function getTaskRunner(TaskInterface $task, InputInterface $input): TaskRunnerInterface
{
// Kick off a simple command immediately when the task runs
$command = new GenerateMerchantFeedCommand();
return new CommandTaskRunner($task, $command, t('Merchant feed generated.'));
}
}
(Task runner approach per docs; here we use `CommandTaskRunner` for a simple, synchronous run. citeturn14view0)
—
### 4) Command + Handler
`packages/merchant_feed/src/Feed/Command/GenerateMerchantFeedCommand.php`
<?php
namespace ConcretePackageMerchantFeedFeedCommand;
use ConcreteCoreFoundationCommandCommand;
class GenerateMerchantFeedCommand extends Command
{
// Add properties for inputs if you need them (e.g., channel, locale, etc.)
}
`packages/merchant_feed/src/Feed/Command/GenerateMerchantFeedCommandHandler.php`
<?php
namespace ConcretePackageMerchantFeedFeedCommand;
use ConcreteCoreSupportFacadeLog;
use ConcreteCoreFileServiceFile as FileService;
class GenerateMerchantFeedCommandHandler
{
public function __invoke(GenerateMerchantFeedCommand $command)
{
// 1) Build your feed data (query products, etc.)
$csv = "id,title,pricen";
$csv .= "SKU-001,Example Product,99.00n";
// 2) Save it somewhere public or in /application/files
$fh = app(FileService::class);
$path = DIR_BASE . '/application/files/merchant_feed.csv';
file_put_contents($path, $csv);
Log::info(sprintf('[merchant_feed] Wrote feed: %s', $path));
}
}
> The handler’s `__invoke()` is where your business logic lives; this pattern is straight from the v9 task docs. citeturn14view0
—
### 5) Enable scheduling on the server (one‑time)
To let dashboard/UI (and programmatic) schedules actually run, **add this cron** (every minute) on your server:
# cPanel-style example:
*/1 * * * * /usr/local/bin/php -q /path/to/site/public/concrete/bin/concrete concrete:scheduler:run >> /dev/null 2>&1
(Concrete’s scheduler daemon reads your schedules and triggers tasks on time.) citeturn13search0
—
## Notes & gotchas
– The “factory” you want in v9 is **TaskService** (not `TaskFactory`). It exposes `getByHandle()` etc. citeturn3view0
– Programmatic scheduling uses **`ConcreteCoreCommandSchedulerScheduler::createScheduledTask()`**, passing your `TaskInterface`, an `InputInterface`, and a cron expression. citeturn10view0
– Schedules appear in **Dashboard → System & Settings → Automation → Schedule** once created. citeturn1search3
If you want it to run more often (e.g., every 15 min), change the cron to `*/15 * * * *`. (Reference cron examples if needed. citeturn0search20)
—
If you paste these files into a `merchant_feed` package and install it, you’ll see the task in **Dashboard → System & Settings → Automation → Tasks**, and the schedule will be created during install (provided the task exists).
Here’s the fixed bit you can drop into your package controller (replacing the broken code):
use ConcreteCoreCommandTaskTaskService;
use ConcreteCoreCommandTaskInputInput; // ✅ use Input directly
use ConcreteCoreCommandSchedulerScheduler;
protected function createDailySchedule(string $handle, string $cron)
{
$app = $this->app;
/** @var TaskService $tasks */
$tasks = $app->make(TaskService::class);
$task = $tasks->getByHandle($handle);
if (!$task) {
return; // task not installed yet
}
// No inputs? Just pass an empty Input instance.
$input = new Input(); // ✅ instead of InputFactory::createFromArray([])
/** @var Scheduler $scheduler */
$scheduler = $app->make(Scheduler::class);
$scheduler->createScheduledTask($task, $input, $cron); // e.g. '0 0 * * *' for daily at midnight
}
If your task needs parameters later, define them via `getInputDefinition()` in the task controller and the UI/CLI will supply them; for programmatic schedules you can still stick with `new Input()` unless you must pre-fill values. The docs show using `new Input()` directly in task runners and explain input definitions. citeturn9view0
Also, ensure the scheduler daemon is set up on the server (one cron entry that runs every minute) so UI/programmatic schedules actually fire. citeturn10search1
Want me to patch the earlier full example with this correction applied end‑to‑end?
Below is a clean, minimal setup you can drop into your package.
—
# 1) Create a Dashboard “Settings” page
**File:** `packages/shopping_feed_manager/controllers/single_page/dashboard/shopping_feed/settings.php`
<?php
namespace ConcretePackageShoppingFeedManagerControllerSinglePageDashboardShoppingFeed;
use ConcreteCorePageControllerDashboardPageController;
use ConcreteCoreSupportFacadeConfig;
use ConcreteCoreValidationCSRFToken;
use CronCronExpression; // comes with Concrete; used for validation
use ConcretePackageShoppingFeedManagerSchedulerSchedulerSync;
defined('C5_EXECUTE') or die('Access Denied.');
class Settings extends DashboardPageController
{
public function view()
{
$this->set('cron', (string) Config::get('shopping_feed_manager.cron', '0 0 * * *')); // default daily at midnight
$this->set('message', null);
$this->set('error', null);
}
public function save()
{
/** @var Token $token */
$token = $this->app->make(Token::class);
if (!$token->validate('sfm_settings_save')) {
$this->error->add(t('Invalid security token. Please reload the page and try again.'));
return $this->view();
}
$cron = trim((string) $this->post('cron'));
if (!$cron) {
$this->error->add(t('Please provide a CRON expression.'));
return $this->view();
}
if (!CronExpression::isValidExpression($cron)) {
$this->error->add(t('The CRON expression is invalid.'));
return $this->view();
}
// Save to config
Config::save('shopping_feed_manager.cron', $cron);
// Re-sync the Concrete scheduler for our task
/** @var SchedulerSync $sync */
$sync = $this->app->make(SchedulerSync::class);
$sync->syncFromConfig($cron);
$this->set('message', t('Settings saved. Schedule updated to "%s".', $cron));
$this->view();
}
}
**File:** `packages/shopping_feed_manager/single_pages/dashboard/shopping_feed/settings.php`
<?php defined('C5_EXECUTE') or die('Access Denied.');
/** @var string $cron */
/** @var ConcreteCoreValidationCSRFToken $token */
$app = ConcreteCoreSupportFacadeApplication::getFacadeApplication();
$form = $app->make('helper/form');
$token = $app->make('token');
?>
<div class="ccm-dashboard-content">
<?php if (isset($message) && $message) { ?>
<div class="alert alert-success"><?= h($message) ?></div>
<?php } ?>
<?php if (isset($error) && $error && $error->has()) { ?>
<div class="alert alert-danger"><?= $error->output(); ?></div>
<?php } ?>
<form method="post" action="<?= $view->action('save'); ?>" class="row gy-3">
<?php $token->output('sfm_settings_save'); ?>
<div class="col-12 col-md-6">
<label class="form-label"><?= t('CRON Expression'); ?></label>
<?= $form->text('cron', $cron, ['placeholder' => 'e.g. */15 * * * *']); ?>
<div class="form-text">
<?= t('Examples:'); ?>
<code>*/15 * * * * </code> (<?= t('every 15 min'); ?>),
<code>0 * * * *</code> (<?= t('hourly'); ?>),
<code>0 0 * * *</code> (<?= t('daily'); ?>)
</div>
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary"><?= t('Save'); ?></button>
</div>
</form>
</div>
**Register the single page on install** (if not already):
// packages/shopping_feed_manager/controller.php (inside install())
use ConcreteCorePageSingle as SinglePage;
...
public function install()
{
$pkg = parent::install();
// Tasks, etc.
$this->installContentFile('tasks.xml');
// Dashboard page
$sp = SinglePage::add('/dashboard/shopping_feed/settings', $pkg);
if ($sp) {
$sp->update(['cName' => t('Feed Settings')]);
}
}
—
# 2) The Scheduler Sync class
This class reads the saved CRON (or uses what you pass) and **updates the Concrete scheduler** for your task.
**File:** `packages/shopping_feed_manager/src/Scheduler/SchedulerSync.php`
<?php
namespace ConcretePackageShoppingFeedManagerScheduler;
use ConcreteCoreSupportFacadeConfig;
use ConcreteCoreCommandTaskTaskService;
use ConcreteCoreCommandTaskInputInput;
use ConcreteCoreCommandSchedulerScheduler;
use DoctrineORMEntityManagerInterface;
class SchedulerSync
{
protected TaskService $tasks;
protected Scheduler $scheduler;
protected EntityManagerInterface $em;
// Handle of the task you registered in tasks.xml and on_start
protected string $taskHandle = 'generate_merchant_feed';
public function __construct(TaskService $tasks, Scheduler $scheduler, EntityManagerInterface $em)
{
$this->tasks = $tasks;
$this->scheduler = $scheduler;
$this->em = $em;
}
/**
* Sync the scheduled task with the current config (or an override).
*/
public function syncFromConfig(?string $cronOverride = null): void
{
$task = $this->tasks->getByHandle($this->taskHandle);
if (!$task) {
return;
}
$cron = $cronOverride ?? (string) Config::get('shopping_feed_manager.cron', '0 0 * * *');
// Remove existing schedules for this task (keep it simple to avoid duplicates)
$this->deleteExistingSchedulesForTask($task->getID());
// Recreate schedule with new CRON
$input = new Input(); // add inputs if your task needs them
$this->scheduler->createScheduledTask($task, $input, $cron);
$this->em->flush();
}
/**
* Delete existing scheduled entries for a task (prevents duplicates when changing CRON).
*/
protected function deleteExistingSchedulesForTask(int $taskId): void
{
// ScheduledTask entity exists in Concrete v9 under Command namespace
$repo = $this->em->getRepository(ConcreteCoreEntityCommandScheduledTask::class);
// Find all schedules for this task
$existing = $repo->findBy(['task' => $taskId]);
foreach ($existing as $schedule) {
$this->em->remove($schedule);
}
}
}
> Notes:
> – We **delete existing schedules** before creating a new one so you don’t end up with duplicates when the user changes the interval.
> – If your Concrete build uses a slightly different namespace for the entity, adjust `ConcreteCoreEntityCommandScheduledTask` accordingly (this class name is correct for Concrete 9.x).
> – If your task needs inputs, replace `new Input()` with an `Input` that has your values (and define them in your Task Controller’s `getInputDefinition()`).
—
# 3) Call sync on install/upgrade (and optional on_start)
In your **package controller**:
use ConcretePackageShoppingFeedManagerSchedulerSchedulerSync;
use ConcreteCoreSupportFacadeConfig;
public function install()
{
$pkg = parent::install();
$this->installContentFile('tasks.xml');
// Default cron if not set
if (!Config::has('shopping_feed_manager.cron')) {
Config::save('shopping_feed_manager.cron', '0 0 * * *'); // daily 00:00
}
// Ensure a schedule exists using current config
$this->app->make(SchedulerSync::class)->syncFromConfig();
}
public function upgrade()
{
parent::upgrade();
$this->installContentFile('tasks.xml');
$this->app->make(SchedulerSync::class)->syncFromConfig();
}
(You don’t need to do it in `on_start()` unless you specifically want to re-sync on every request; saving from the settings page already triggers a sync.)
—
# 4) Make sure your task is registered
You should already have your task defined and wired (per your earlier setup):
– `tasks.xml` includes “
– In `on_start()` you extend the task handle with your Task Controller:
$manager = $this->app->make(ConcreteCoreCommandTaskManager::class);
$manager->extend('generate_merchant_feed', function () {
return new ConcretePackageShoppingFeedManagerCommandTaskControllerGenerateMerchantFeedController();
});
—
# 5) (One-time) server cron
Ensure the Concrete **scheduler runner** is invoked every minute:
*/1 * * * * /usr/bin/php -q /path/to/site/public/concrete/bin/concrete concrete:scheduler:run >/dev/null 2>&1
—
## That’s it
– User edits the **CRON** on `/dashboard/shopping_feed/settings`
– Clicks **Save**
– We validate the expression, write it to config, and **rebuild the Concrete schedule** for your task
If you want preset dropdowns (Every 5/15/30 min, Hourly, Daily), just render a “ and map the choice to a cron string before saving.
$this->createDailySchedule('generate_merchant_feed', '0 0 * * *');
from `on_start()`.
Here’s why:
### 🔎 How `on_start()` works in Concrete 9
– `on_start()` runs on **every page load** while your package is enabled.
– If you create schedules there, you’ll try to **re-insert the schedule on every request**, which can:
– create duplicates (unless you delete first each time),
– add unnecessary DB work on every page view.
### ✅ Best practice
– Create/update the schedule when:
1. **installing/upgrading** the package, OR
2. **saving your settings** in your dashboard page (like we did in `SchedulerSync::syncFromConfig()`).
That way the schedule only changes when you explicitly want it to.
### 👌 If you *do* want to ensure it exists in `on_start()`
You can call it safely if you guard against duplicates. Example:
public function on_start()
{
$this->app->make(ConcretePackageShoppingFeedManagerSchedulerSchedulerSync::class)
->syncFromConfig();
}
This uses your `SchedulerSync` class to **delete old schedules and re-create the one** from config. That makes it idempotent (no duplicates), but it still runs on every request.
So it’s “safe”, but not the most efficient—better to only run on install/upgrade/settings save.
—
👉 Do you want me to show you a **hybrid pattern** where `on_start()` only ensures the schedule exists if it’s missing (instead of recreating every time)?
0 * * * *
That means: *at minute 0 of every hour*.
—
### Example: inside your package controller
public function install()
{
$pkg = parent::install();
$this->installContentFile('tasks.xml');
// Default cron: every hour
if (!Config::has('shopping_feed_manager.cron')) {
Config::save('shopping_feed_manager.cron', '0 * * * *');
}
$this->app->make(ConcretePackageShoppingFeedManagerSchedulerSchedulerSync::class)
->syncFromConfig();
}
—
### Example: calling manually (if you still want a helper)
$this->createDailySchedule('generate_merchant_feed', '0 * * * *');
🔁 In this case your helper should probably be renamed to `createSchedule()` since it’s not “daily” anymore 😅
—
### Summary
– **Hourly cron string** → `0 * * * *`
– Use that in `Config::save(…)` or in your `createSchedule()` call.
– The rest of your scheduler logic stays the same.
—
Want me to give you a little **ready-to-paste version of `createSchedule()`** that accepts any cron expression and only adds the schedule if it doesn’t already exist?
It accepts **any cron expression**, avoids duplicates, and (re)enables the schedule if it already exists.
use ConcreteCoreCommandTaskTaskService;
use ConcreteCoreCommandTaskInputInput;
use ConcreteCoreCommandSchedulerScheduler;
use DoctrineORMEntityManagerInterface;
/**
* Create (or ensure) a schedule for a task.
*
* @param string $handle Task handle registered via tasks.xml and on_start() extend()
* @param string $cron Cron expression, e.g. '0 * * * *' (hourly), '0 0 * * *' (daily)
* @param bool $enable Whether the schedule should be enabled
*/
protected function createSchedule(string $handle, string $cron, bool $enable = true): void
{
$app = $this->app;
/** @var TaskService $tasks */
$tasks = $app->make(TaskService::class);
$task = $tasks->getByHandle($handle);
if (!$task) {
return; // Task not installed/registered yet
}
/** @var EntityManagerInterface $em */
$em = $app->make('database/orm')->entityManager();
$repo = $em->getRepository(ConcreteCoreEntityCommandScheduledTask::class);
// Find any existing schedules for this task
$existing = $repo->findBy(['task' => $task->getID()]);
// If a schedule with the same cron exists, just ensure its enabled flag is correct and bail
foreach ($existing as $schedule) {
if (method_exists($schedule, 'getCronExpression') && $schedule->getCronExpression() === $cron) {
if (method_exists($schedule, 'setIsEnabled')) {
$schedule->setIsEnabled($enable);
$em->flush();
}
return;
}
}
// Otherwise, clean up any old schedules to avoid duplicates
foreach ($existing as $schedule) {
$em->remove($schedule);
}
$em->flush();
// Create a fresh scheduled task
/** @var Scheduler $scheduler */
$scheduler = $app->make(Scheduler::class);
$input = new Input(); // Add inputs here if your task requires any
// Signature supports the enabled flag as the 4th param in Concrete 9
$scheduler->createScheduledTask($task, $input, $cron, $enable);
$em->flush();
}
### How to use it
**Hourly:**
$this->createSchedule('generate_merchant_feed', '0 * * * *'); // every hour at :00
**Every 15 minutes:**
$this->createSchedule('generate_merchant_feed', '*/15 * * * *');
**Daily at midnight:**
$this->createSchedule('generate_merchant_feed', '0 0 * * *');
### Where to call it
– **Best:** during `install()`, `upgrade()`, and when saving your **Settings** page.
– **Avoid:** calling on every request in `on_start()` (unless you *really* need to).
If you must, it’s safe because the helper is idempotent, but it still adds DB work.
If you want, I can also show a tiny wrapper that reads the cron from `Config::get('shopping_feed_manager.cron')` and calls this helper—handy for your settings-save flow.