Auto-run task interval
August 23, 2025 · AI Conversations
Here’s a clean, Concrete CMS 9–style way to do it:
—
# 1) Keep your task registered (you already did)
Your `tasks.xml`/CIF is fine:
<concrete5-cif version="1.0">
<tasks>
<task handle="generate_merchant_feed" package="shopping_feed_manager"/>
</tasks>
<tasksets>
<taskset handle="community_store">
<task handle="generate_merchant_feed"/>
</taskset>
</tasksets>
</concrete5-cif>
—
# 2) Add a “Run every N minutes” field in your package settings page
Store it in config, e.g. `shopping_feed_manager::interval_minutes`.
Example controller for your dashboard Single Page (pseudo‑paths shown):
// packages/shopping_feed_manager/controllers/single_page/dashboard/shopping_feed/settings.php
namespace ConcretePackageShoppingFeedManagerControllerSinglePageDashboardShoppingFeed;
use ConcreteCorePageControllerDashboardPageController;
use ConcreteCoreSupportFacadeConfig;
use ConcreteCoreHttpResponseFactoryInterface;
use ConcreteCoreErrorUserMessageException;
use ConcreteCoreValidationCSRFToken;
class Settings extends DashboardPageController
{
public function view()
{
$this->set('interval', (int) Config::get('shopping_feed_manager::interval_minutes', 30));
}
public function save()
{
/** @var Token $token */
$token = $this->app->make(Token::class);
if (!$token->validate('sfm_settings')) {
throw new UserMessageException(t('Invalid token.'));
}
$minutes = (int) $this->request->request->get('interval');
if ($minutes < 1) {
$minutes = 1; // minimum 1 minute (or set your own floor)
}
Config::save('shopping_feed_manager::interval_minutes', $minutes);
// Update the schedule immediately when saving:
$this->app->make(ConcretePackageShoppingFeedManagerSchedulerFeedScheduler::class)
->createOrUpdateSchedule($minutes);
$this->flash('success', t('Settings saved. Task schedule updated to every %s minute(s).', $minutes));
return $this->buildRedirect($this->action('view'));
}
}
Simple view:
// packages/shopping_feed_manager/single_pages/dashboard/shopping_feed/settings.php
defined('C5_EXECUTE') or die('Access Denied.');
/** @var ConcreteCoreFormServiceForm $form */
$form = app('helper/form');
$token = app('token');
?>
<form method="post" action="<?=$view->action('save');?>">
<?=$token->output('sfm_settings');?>
<div class="mb-3">
<label class="form-label"><?=t('Run every (minutes)');?></label>
<?=$form->number('interval', $interval, ['min' => 1, 'step' => 1]);?>
<div class="form-text"><?=t('How frequently to run the Google Merchant feed generator.');?></div>
</div>
<button type="submit" class="btn btn-primary"><?=t('Save');?></button>
</form>
—
# 3) Programmatically create/update the **On‑Schedule Trigger**
Create a tiny helper class to (a) find your task, (b) delete any old schedule triggers for it, (c) make a new “every N minutes” schedule, and (d) persist it.
// packages/shopping_feed_manager/src/Scheduler/FeedScheduler.php
namespace ConcretePackageShoppingFeedManagerScheduler;
use ConcreteCoreApplicationApplicationAwareInterface;
use ConcreteCoreApplicationApplicationAwareTrait;
use ConcreteCoreEntityAutomationTask;
use ConcreteCoreEntityAutomationTriggerOnScheduleTrigger;
use ConcreteCoreEntityManagerEntityManager;
use ConcreteCoreSupportFacadeApplication;
use ConcreteCoreAutomationTaskTaskFactory;
use ConcreteCoreAutomationScheduleScheduleFactory;
use ConcreteCoreAutomationTriggerOnScheduleTriggerFactory;
class FeedScheduler implements ApplicationAwareInterface
{
use ApplicationAwareTrait;
protected EntityManager $em;
protected TaskFactory $taskFactory;
protected ScheduleFactory $scheduleFactory;
protected OnScheduleTriggerFactory $triggerFactory;
public function __construct(
EntityManager $em,
TaskFactory $taskFactory,
ScheduleFactory $scheduleFactory,
OnScheduleTriggerFactory $triggerFactory
) {
$this->em = $em;
$this->taskFactory = $taskFactory;
$this->scheduleFactory = $scheduleFactory;
$this->triggerFactory = $triggerFactory;
}
public function createOrUpdateSchedule(int $minutes): void
{
// 1) Get the Task by handle
/** @var Task|null $task */
$task = $this->taskFactory->getByHandle('generate_merchant_feed');
if (!$task) {
// If the task isn’t installed yet, bail out silently or throw.
return;
}
// 2) Remove existing schedule triggers for this task
$repo = $this->em->getRepository(OnScheduleTrigger::class);
$existing = $repo->findBy(['task' => $task]);
foreach ($existing as $trigger) {
$this->em->remove($trigger);
}
$this->em->flush();
// 3) Create a fresh schedule: every N minutes
// (Concrete CMS 9 provides helpers like createEveryXMinutes)
$schedule = $this->scheduleFactory->createEveryXMinutes($minutes);
// 4) Create and persist the trigger (links schedule + task)
$trigger = $this->triggerFactory->create($schedule, $task);
$this->em->persist($trigger);
$this->em->flush();
}
}
> Notes:
> – Class names here follow Concrete v9 Automation APIs. If your exact namespaces differ in your install, adjust imports (the idea is the same: **Task** + **Schedule** + **OnScheduleTrigger**).
> – If you keep multiple schedules (e.g., one per site), remove the delete loop and add logic to update or add per context.
Wire the class into the container (optional; Concrete auto‑resolves many):
// packages/shopping_feed_manager/controller.php (Package class)
public function on_start()
{
$this->app->bind(
ConcretePackageShoppingFeedManagerSchedulerFeedScheduler::class,
static function ($app) {
return new ConcretePackageShoppingFeedManagerSchedulerFeedScheduler(
$app->make('DoctrineORMEntityManager'),
$app->make(ConcreteCoreAutomationTaskTaskFactory::class),
$app->make(ConcreteCoreAutomationScheduleScheduleFactory::class),
$app->make(ConcreteCoreAutomationTriggerOnScheduleTriggerFactory::class),
);
}
);
}
During **install/upgrade**, seed a default interval and schedule:
public function install()
{
$pkg = parent::install();
Config::save('shopping_feed_manager::interval_minutes', 30);
$this->app->make(ConcretePackageShoppingFeedManagerSchedulerFeedScheduler::class)
->createOrUpdateSchedule(30);
}
public function upgrade()
{
parent::upgrade();
$minutes = (int) Config::get('shopping_feed_manager::interval_minutes', 30);
$this->app->make(ConcretePackageShoppingFeedManagerSchedulerFeedScheduler::class)
->createOrUpdateSchedule($minutes);
}
—
# 4) Make sure the server actually *runs* the due schedules
Concrete’s Automation runner needs to be invoked periodically. Set a cron to run **every minute**:
* * * * * /path/to/php /path/to/concrete/bin/concrete automation:run >/dev/null 2>&1
– This command finds due **On‑Schedule** triggers (like the one you just created) and executes the associated Task.
– Your task controller (e.g., `CommandTaskControllerGenerateMerchantFeedController`) should focus purely on doing the work; the **schedule** handles the *when*.
—
# 5) (Optional) Show current next run in your settings UI
You can read back the `OnScheduleTrigger` for your task and display the schedule description / next fire time to reassure admins that it’s set correctly.
—
## Summary
– Store interval in config via your settings page.
– On save (and on install/upgrade), **create or update an On‑Schedule trigger** for your `generate_merchant_feed` task with “every N minutes”.
– Ensure a system cron runs `automation:run` once per minute.
If you want, paste your Task Controller class here and I’ll double‑check it’s compatible with Automation (input/output, logging, queue usage).
<?php defined('C5_EXECUTE') or die("Access Denied.");
use ConcreteCoreFormServiceForm;
use ConcreteCoreValidationCSRFToken;
/** @var bool $notifyOnError */
/** @var bool $enableCron */
/** @var string $notifyEmail */
/** @var int $interval Minutes; provide from controller, default fallback below */
/** @var string $scheduleSummary Optional string like "Every 30 minutes" or "Next run: ..." */
$form = app(Form::class);
$token = app(Token::class);
$interval = isset($interval) ? (int) $interval : 30;
?>
<div class="ccm-dashboard-content">
<div class="row">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header d-flex align-items-center justify-content-between">
<h5 class="mb-0"><?= t('General Settings') ?></h5>
<?php if (!empty($scheduleSummary)) { ?>
<span class="text-muted small">
<i class="fas fa-clock me-1"></i><?= h($scheduleSummary) ?>
</span>
<?php } ?>
</div>
<div class="card-body">
<form method="post" action="<?= $this->action('save') ?>" class="ccm-dashboard-form">
<?= $token->output('save') ?>
<div class="row g-3">
<div class="col-md-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="notify_on_error" value="1" id="notify_on_error"
<?= $notifyOnError ? 'checked' : '' ?>>
<label class="form-check-label" for="notify_on_error">
<?= t('Notify me on error') ?>
</label>
</div>
<div class="form-text"><?= t('Receive email notifications when feed generation fails.') ?></div>
</div>
<div class="col-md-6">
<div class="form-check">
<input class="form-check-input js-toggle-cron" type="checkbox" name="enable_cron" value="1" id="enable_cron"
<?= $enableCron ? 'checked' : '' ?>>
<label class="form-check-label" for="enable_cron">
<?= t('Enable Cron Run') ?>
</label>
</div>
<div class="form-text"><?= t('Automatically regenerate feeds on schedule.') ?></div>
</div>
<div class="col-md-6">
<label for="notify_email" class="form-label"><?= t('Notification Email') ?></label>
<input type="email"
class="form-control"
name="notify_email"
id="notify_email"
value="<?= h($notifyEmail) ?>"
placeholder="admin@example.com">
<div class="form-text"><?= t('Email address for error notifications.') ?></div>
</div>
<!-- New: Interval (minutes) -->
<div class="col-md-6">
<label for="interval" class="form-label"><?= t('Run every (minutes)') ?></label>
<input type="number"
class="form-control js-interval"
name="interval"
id="interval"
min="1"
step="1"
value="<?= (int) $interval ?>"
<?= $enableCron ? '' : 'disabled' ?>>
<div class="form-text">
<?= t('How frequently to run the feed generator. Minimum 1 minute.') ?>
</div>
</div>
</div>
<div class="ccm-dashboard-form-actions-wrapper mt-4">
<div class="ccm-dashboard-form-actions">
<a href="<?= h((string) ConcreteCoreSupportFacadeUrl::to('/dashboard/shopping_feed')) ?>" class="btn btn-secondary">
<?= t('Cancel') ?>
</a>
<button class="btn btn-primary float-end" type="submit">
<?= t('Save Settings') ?>
</button>
</div>
</div>
</form>
</div>
</div>
<script>
(function () {
const toggle = document.querySelector('.js-toggle-cron');
const interval = document.querySelector('.js-interval');
function sync() {
if (!toggle || !interval) return;
interval.disabled = !toggle.checked;
}
if (toggle) {
toggle.addEventListener('change', sync);
// initial state
sync();
}
})();
</script>
</div>
</div>
</div>
### Controller notes (for completeness)
– Ensure your controller sets `$interval` (int) and optional `$scheduleSummary` for the header.
– On `save`, read `interval` (floor at 1), persist to config, and call your scheduler helper to create/update the **On‑Schedule** trigger.
– Enables/disables the schedule for your `generate_merchant_feed` task
– Updates the interval (every N minutes)
– Cleans up old schedule triggers
– Can return a friendly summary (“Every 30 minutes”, etc.)
Create this file:
`packages/shopping_feed_manager/src/Scheduler/FeedScheduler.php`
<?php
declare(strict_types=1);
namespace ConcretePackageShoppingFeedManagerScheduler;
use ConcreteCoreApplicationApplicationAwareInterface;
use ConcreteCoreApplicationApplicationAwareTrait;
use ConcreteCoreErrorUserMessageException;
use ConcreteCoreUtilityServiceText;
use DoctrineORMEntityManager;
use ConcreteCoreAutomationTaskTaskFactory;
use ConcreteCoreAutomationScheduleScheduleFactory;
use ConcreteCoreAutomationTriggerOnScheduleTriggerFactory;
use ConcreteCoreEntityAutomationTask;
use ConcreteCoreEntityAutomationTriggerOnScheduleTrigger;
class FeedScheduler implements ApplicationAwareInterface
{
use ApplicationAwareTrait;
/** @var EntityManager */
protected $em;
/** @var TaskFactory */
protected $taskFactory;
/** @var ScheduleFactory */
protected $scheduleFactory;
/** @var OnScheduleTriggerFactory */
protected $triggerFactory;
public function __construct(
EntityManager $em,
TaskFactory $taskFactory,
ScheduleFactory $scheduleFactory,
OnScheduleTriggerFactory $triggerFactory
) {
$this->em = $em;
$this->taskFactory = $taskFactory;
$this->scheduleFactory = $scheduleFactory;
$this->triggerFactory = $triggerFactory;
}
/**
* Create/Update the on-schedule trigger for the feed task.
*
* @param bool $enabled Whether the schedule should be active
* @param int $minutes Interval in minutes (min 1 if enabled)
*/
public function createOrUpdateSchedule(bool $enabled, int $minutes = 30): void
{
/** @var Task|null $task */
$task = $this->taskFactory->getByHandle('generate_merchant_feed');
if (!$task) {
// Task not installed – nothing to schedule.
throw new UserMessageException(t('The task "generate_merchant_feed" is not installed.'));
}
// Remove existing schedule triggers for this task
$repo = $this->em->getRepository(OnScheduleTrigger::class);
$existing = $repo->findBy(['task' => $task]);
foreach ($existing as $trigger) {
$this->em->remove($trigger);
}
$this->em->flush();
if (!$enabled) {
// User disabled cron – just return after cleanup.
return;
}
$minutes = max(1, (int) $minutes);
// Create a new "every N minutes" schedule
$schedule = $this->scheduleFactory->createEveryXMinutes($minutes);
// Link schedule to task via an on-schedule trigger
$trigger = $this->triggerFactory->create($schedule, $task);
$this->em->persist($trigger);
$this->em->flush();
}
/**
* Returns a short human string for the current schedule, or null if none.
* Example: "Every 30 minutes"
*/
public function getScheduleSummary(): ?string
{
/** @var Task|null $task */
$task = $this->taskFactory->getByHandle('generate_merchant_feed');
if (!$task) {
return null;
}
$repo = $this->em->getRepository(OnScheduleTrigger::class);
/** @var OnScheduleTrigger[] $triggers */
$triggers = $repo->findBy(['task' => $task], ['id' => 'DESC']);
if (!count($triggers)) {
return null;
}
// We only create one; take the newest just in case
$trigger = $triggers[0];
$schedule = $trigger->getSchedule();
if (!$schedule) {
return null;
}
// Most Concrete schedules expose a description
if (method_exists($schedule, 'getDescription')) {
$desc = (string) $schedule->getDescription();
if ($desc) {
return $desc;
}
}
// Fallback: inspect common schedule types
// Many schedules store the interval; try to read it
if (method_exists($schedule, 'getMinutes')) {
$m = (int) $schedule->getMinutes();
if ($m > 0) {
return t('Every %s minute(s)', $m);
}
}
return t('Scheduled');
}
}
### (Optional) Bind in your package `on_start` (helps with IDE/type‑safe DI)
In `packages/shopping_feed_manager/controller.php` inside your Package class:
public function on_start()
{
$this->app->bind(
ConcretePackageShoppingFeedManagerSchedulerFeedScheduler::class,
static function ($app) {
return new ConcretePackageShoppingFeedManagerSchedulerFeedScheduler(
$app->make('DoctrineORMEntityManager'),
$app->make(ConcreteCoreAutomationTaskTaskFactory::class),
$app->make(ConcreteCoreAutomationScheduleScheduleFactory::class),
$app->make(ConcreteCoreAutomationTriggerOnScheduleTriggerFactory::class),
);
}
);
}
### How to call it (from your Dashboard controller `save()`):
// After validating and saving settings:
$enableCron = (bool) $this->request->request->get('enable_cron');
$interval = (int) $this->request->request->get('interval');
$this->app->make(ConcretePackageShoppingFeedManagerSchedulerFeedScheduler::class)
->createOrUpdateSchedule($enableCron, $interval);
### Seed on install/upgrade:
public function install()
{
$pkg = parent::install();
Config::save('shopping_feed_manager::interval_minutes', 30);
Config::save('shopping_feed_manager::enable_cron', true);
$this->app->make(ConcretePackageShoppingFeedManagerSchedulerFeedScheduler::class)
->createOrUpdateSchedule(true, 30);
}
public function upgrade()
{
parent::upgrade();
$enabled = (bool) Config::get('shopping_feed_manager::enable_cron', true);
$minutes = (int) Config::get('shopping_feed_manager::interval_minutes', 30);
$this->app->make(ConcretePackageShoppingFeedManagerSchedulerFeedScheduler::class)
->createOrUpdateSchedule($enabled, $minutes);
}
> Don’t forget the system cron:
>
> * * * * * /path/to/php /path/to/concrete/bin/concrete automation:run >/dev/null 2>&1
>
If you want this to also **pause** runs while keeping the last schedule record, we can switch from “delete & recreate” to “toggle enabled” — but for most cases, the above clean replace is simplest and avoids duplicates.
Since in your Dashboard `save()` you’re already saving values, you can just read them back wherever you need.
—
### 1. Saving the interval in your Dashboard controller
public function save()
{
$token = $this->app->make('token');
if (!$token->validate('save')) {
$this->error->add($token->getErrorMessage());
return $this->view();
}
$interval = max(1, (int) $this->request->request->get('interval'));
$enableCron = (bool) $this->request->request->get('enable_cron');
$notifyOnError = (bool) $this->request->request->get('notify_on_error');
$notifyEmail = trim((string) $this->request->request->get('notify_email'));
// Save into config
Config::save('shopping_feed_manager::interval_minutes', $interval);
Config::save('shopping_feed_manager::enable_cron', $enableCron);
Config::save('shopping_feed_manager::notify_on_error', $notifyOnError);
Config::save('shopping_feed_manager::notify_email', $notifyEmail);
// Update the automation schedule
$this->app->make(ConcretePackageShoppingFeedManagerSchedulerFeedScheduler::class)
->createOrUpdateSchedule($enableCron, $interval);
$this->flash('success', t('Settings saved.'));
return $this->buildRedirect($this->action('view'));
}
—
### 2. Loading the interval into your Dashboard `view()`
public function view()
{
$this->set('interval', (int) Config::get('shopping_feed_manager::interval_minutes', 30));
$this->set('enableCron', (bool) Config::get('shopping_feed_manager::enable_cron', true));
$this->set('notifyOnError', (bool) Config::get('shopping_feed_manager::notify_on_error', false));
$this->set('notifyEmail', (string) Config::get('shopping_feed_manager::notify_email', ''));
}
—
### 3. Getting the interval anywhere else (e.g. in a service or the Task itself)
$minutes = (int) Config::get('shopping_feed_manager::interval_minutes', 30);
This returns the stored interval (default 30 if nothing saved).
—
✅ **Summary:**
– Save with `Config::save('shopping_feed_manager::interval_minutes', $value)`.
– Read with `Config::get('shopping_feed_manager::interval_minutes', $default)`.
That way your `FeedScheduler` or even the Task itself can always fetch the user-defined interval.
Do you also want me to show how to **auto-inject the interval into FeedScheduler** (so it doesn’t need the controller to pass it each time)?
### Drop‑in version (reads from Config)
<?php
declare(strict_types=1);
namespace ConcretePackageShoppingFeedManagerScheduler;
use DoctrineORMEntityManager;
use ConcreteCoreAutomationTaskTaskFactory;
use ConcreteCoreAutomationScheduleScheduleFactory;
use ConcreteCoreAutomationTriggerOnScheduleTriggerFactory;
use ConcreteCoreEntityAutomationTask;
use ConcreteCoreEntityAutomationTriggerOnScheduleTrigger;
use ConcreteCoreErrorUserMessageException;
class FeedScheduler
{
public function __construct(
protected EntityManager $em,
protected TaskFactory $taskFactory,
protected ScheduleFactory $scheduleFactory,
protected OnScheduleTriggerFactory $triggerFactory
) {}
/** Read settings saved by your dashboard page */
protected function getSettings(): array
{
$enabled = (bool) Config::get('shopping_feed_manager::enable_cron', true);
$minutes = (int) Config::get('shopping_feed_manager::interval_minutes', 30);
$minutes = max(1, $minutes); // safety floor
return ['enabled' => $enabled, 'minutes' => $minutes];
}
/**
* One‑liner to sync the DB triggers with the current settings.
* Call this after install/upgrade/save() or from a job/command if you want.
*/
public function syncFromConfig(): void
{
$s = $this->getSettings();
$this->createOrUpdateSchedule($s['enabled'], $s['minutes']);
}
/**
* Create/Update the on‑schedule trigger for the task.
* You can still call this directly if you want to override config.
*/
public function createOrUpdateSchedule(bool $enabled, int $minutes = 30): void
{
/** @var Task|null $task */
$task = $this->taskFactory->getByHandle('generate_merchant_feed');
if (!$task) {
throw new UserMessageException(t('The task "generate_merchant_feed" is not installed.'));
}
// Remove any existing schedule triggers for this task
$repo = $this->em->getRepository(OnScheduleTrigger::class);
foreach ($repo->findBy(['task' => $task]) as $trigger) {
$this->em->remove($trigger);
}
$this->em->flush();
if (!$enabled) {
// User disabled scheduling — we’re done after cleanup
return;
}
$minutes = max(1, (int) $minutes);
$schedule = $this->scheduleFactory->createEveryXMinutes($minutes);
$trigger = $this->triggerFactory->create($schedule, $task);
$this->em->persist($trigger);
$this->em->flush();
}
/** Optional: show “Every N minutes” based on the current trigger */
public function getScheduleSummary(): ?string
{
$task = $this->taskFactory->getByHandle('generate_merchant_feed');
if (!$task) {
return null;
}
$repo = $this->em->getRepository(OnScheduleTrigger::class);
$trigger = $repo->findOneBy(['task' => $task], ['id' => 'DESC']);
if (!$trigger) {
return null;
}
$schedule = $trigger->getSchedule();
if (!$schedule) {
return null;
}
// Prefer schedule description if available
if (method_exists($schedule, 'getDescription')) {
$desc = (string) $schedule->getDescription();
if ($desc) return $desc;
}
if (method_exists($schedule, 'getMinutes')) {
return t('Every %s minute(s)', (int) $schedule->getMinutes());
}
return t('Scheduled');
}
}
### How to use it
– From your dashboard controller `save()` (simple & explicit):
$this->app->make(ConcretePackageShoppingFeedManagerSchedulerFeedScheduler::class)
->syncFromConfig();
– From `install()`/`upgrade()` (seed or resync after package changes):
Config::save('shopping_feed_manager::interval_minutes', 30);
Config::save('shopping_feed_manager::enable_cron', true);
$this->app->make(ConcretePackageShoppingFeedManagerSchedulerFeedScheduler::class)
->syncFromConfig();
Now the scheduler **pulls the interval straight from your settings** whenever you call `syncFromConfig()`. If you still want to override on the fly, you can call `createOrUpdateSchedule($enabled, $minutes)` directly.
### 1) Make sure your `src/` is autoloaded
If you’re not using Composer in the package, add this to your controller:
// packages/shopping_feed_manager/controller.php
namespace ConcretePackageShoppingFeedManager;
use ConcreteCorePackagePackage;
class Controller extends Package
{
protected $pkgHandle = 'shopping_feed_manager';
protected $appVersionRequired = '9.0.0';
protected $pkgVersion = '1.0.0';
// 🔸 Autoload /src
public function getPackageAutoloaderRegistries()
{
return [
'src' => 'ConcretePackageShoppingFeedManager',
];
}
// ...
}
### 2) Import the classes you’ll call
use ConcretePackageShoppingFeedManagerSchedulerFeedScheduler;
use ConcreteCoreSupportFacadeConfig;
### 3) (Optional) Bind the scheduler in `on_start`
Concrete can auto‑resolve, but binding is nice for IDEs and clarity:
public function on_start()
{
$this->app->bind(FeedScheduler::class, function ($app) {
return new FeedScheduler(
$app->make('DoctrineORMEntityManager'),
$app->make(ConcreteCoreAutomationTaskTaskFactory::class),
$app->make(ConcreteCoreAutomationScheduleScheduleFactory::class),
$app->make(ConcreteCoreAutomationTriggerOnScheduleTriggerFactory::class),
);
});
}
### 4) Call it during install / upgrade
Seed defaults (if not set) and sync the schedule from config.
public function install()
{
$pkg = parent::install();
// seed defaults (only if you want initial values)
if (Config::get('shopping_feed_manager::interval_minutes') === null) {
Config::save('shopping_feed_manager::interval_minutes', 30);
}
if (Config::get('shopping_feed_manager::enable_cron') === null) {
Config::save('shopping_feed_manager::enable_cron', true);
}
// create/update the Automation trigger from current config
$this->app->make(FeedScheduler::class)->syncFromConfig();
}
public function upgrade()
{
parent::upgrade();
// keep schedule in sync after upgrades
$this->app->make(FeedScheduler::class)->syncFromConfig();
}
### 5) (Optional) Helper you can reuse anywhere
If you like, add a tiny helper method to your controller to resync:
protected function resyncFeedSchedule(): void
{
$this->app->make(FeedScheduler::class)->syncFromConfig();
}
Then you can call `$this->resyncFeedSchedule();` from tasks, migrations, etc.
—
That’s it. With this in place, your Dashboard `save()` just updates config; calling `FeedScheduler::syncFromConfig()` (e.g., right after saving) will recreate the **On‑Schedule** trigger with the latest **Enable** + **Interval** settings.
Here’s a drop‑in tweak to your `FeedScheduler` that guarantees the trigger is created, enabled, and visible in that screen:
// packages/shopping_feed_manager/src/Scheduler/FeedScheduler.php
namespace ConcretePackageShoppingFeedManagerScheduler;
use DoctrineORMEntityManager;
use ConcreteCoreAutomationTaskTaskFactory;
use ConcreteCoreAutomationScheduleScheduleFactory;
use ConcreteCoreAutomationTriggerOnScheduleTriggerFactory;
use ConcreteCoreEntityAutomationTask;
use ConcreteCoreEntityAutomationTriggerOnScheduleTrigger;
use ConcreteCoreErrorUserMessageException;
class FeedScheduler
{
public function __construct(
protected EntityManager $em,
protected TaskFactory $taskFactory,
protected ScheduleFactory $scheduleFactory,
protected OnScheduleTriggerFactory $triggerFactory
) {}
public function syncFromConfig(): void
{
$enabled = (bool) Config::get('shopping_feed_manager::enable_cron', true);
$minutes = max(1, (int) Config::get('shopping_feed_manager::interval_minutes', 30));
$this->createOrUpdateSchedule($enabled, $minutes);
}
public function createOrUpdateSchedule(bool $enabled, int $minutes): void
{
/** @var Task|null $task */
$task = $this->taskFactory->getByHandle('generate_merchant_feed');
if (!$task) {
throw new UserMessageException(t('The task "generate_merchant_feed" is not installed.'));
}
// Remove old on-schedule triggers for this task so we keep exactly one visible entry
$repo = $this->em->getRepository(OnScheduleTrigger::class);
foreach ($repo->findBy(['task' => $task]) as $old) {
$this->em->remove($old);
}
$this->em->flush();
if (!$enabled) {
// Disabled: no trigger -> nothing will appear in the list
return;
}
// Create schedule & trigger (these are the entities that the UI lists)
$minutes = max(1, (int) $minutes);
$schedule = $this->scheduleFactory->createEveryXMinutes($minutes);
// Optional: if your Schedule object supports description/notes, set it for nicer UI
if (method_exists($schedule, 'setDescription')) {
$schedule->setDescription(t('Every %s minute(s) — Google Merchant feed', $minutes));
} elseif (method_exists($schedule, 'setNotes')) {
$schedule->setNotes(t('Every %s minute(s) — Google Merchant feed', $minutes));
}
// Persist schedule explicitly to be safe
$this->em->persist($schedule);
// Link schedule <-> task via OnSchedule trigger
$trigger = $this->triggerFactory->create($schedule, $task);
// Make sure the trigger is enabled so it shows and runs
if (method_exists($trigger, 'setIsEnabled')) {
$trigger->setIsEnabled(true);
}
$this->em->persist($trigger);
$this->em->flush();
}
}
### Key points to show it in the UI
– You **must** have an **`OnScheduleTrigger`** entity persisted and (ideally) enabled.
– The **Schedule** entity should be persisted (done above).
– If you **disable** scheduling in your settings, we delete the trigger — then it won’t appear (by design). If you’d rather keep it visible but disabled, don’t delete; instead set `setIsEnabled(false)` on the trigger.
### If you prefer “keep but disabled” (still visible)
Replace the delete‑and‑recreate logic with “find or make, then toggle enabled + update schedule interval”:
$trigger = $repo->findOneBy(['task' => $task]);
if (!$trigger) {
$schedule = $this->scheduleFactory->createEveryXMinutes($minutes);
$this->em->persist($schedule);
$trigger = $this->triggerFactory->create($schedule, $task);
$this->em->persist($trigger);
} else {
// Update the existing trigger’s schedule to new interval
$schedule = $trigger->getSchedule();
if (method_exists($schedule, 'setMinutes')) {
$schedule->setMinutes($minutes);
} else {
// fallback: replace schedule entity
$this->em->remove($schedule);
$schedule = $this->scheduleFactory->createEveryXMinutes($minutes);
$this->em->persist($schedule);
// If trigger has a setter, reattach
if (method_exists($trigger, 'setSchedule')) {
$trigger->setSchedule($schedule);
}
}
}
if (method_exists($trigger, 'setIsEnabled')) {
$trigger->setIsEnabled($enabled);
}
$this->em->flush();
### Don’t forget to call `syncFromConfig()`
– After saving settings (in your dashboard controller)
– On package `install()` / `upgrade()`
Once this runs with `enable_cron = true`, you’ll see an **On‑Schedule** entry for your task on `/dashboard/system/automation/schedule`.
when added this in on_Start
$scheduler = $this->app->make(FeedScheduler::class);
$scheduler->syncFromConfig();
Here’s a drop‑in update.
# 1) Update `FeedScheduler` to use Doctrine only
`packages/shopping_feed_manager/src/Scheduler/FeedScheduler.php`
<?php
declare(strict_types=1);
namespace ConcretePackageShoppingFeedManagerScheduler;
use DoctrineORMEntityManager;
use ConcreteCoreAutomationScheduleScheduleFactory;
use ConcreteCoreAutomationTriggerOnScheduleTriggerFactory;
use ConcreteCoreEntityAutomationTask;
use ConcreteCoreEntityAutomationTriggerOnScheduleTrigger;
use ConcreteCoreErrorUserMessageException;
class FeedScheduler
{
public function __construct(
protected EntityManager $em,
protected ScheduleFactory $scheduleFactory,
protected OnScheduleTriggerFactory $triggerFactory
) {}
/** Read settings from config */
protected function getSettings(): array
{
$enabled = (bool) Config::get('shopping_feed_manager::enable_cron', true);
$minutes = max(1, (int) Config::get('shopping_feed_manager::interval_minutes', 30));
return ['enabled' => $enabled, 'minutes' => $minutes];
}
/** Call this after install/upgrade/save() */
public function syncFromConfig(): void
{
$s = $this->getSettings();
$this->createOrUpdateSchedule($s['enabled'], $s['minutes']);
}
public function createOrUpdateSchedule(bool $enabled, int $minutes): void
{
// ✅ Get Task via Doctrine (no TaskFactory)
$taskRepo = $this->em->getRepository(Task::class);
/** @var Task|null $task */
$task = $taskRepo->findOneBy(['handle' => 'generate_merchant_feed']);
if (!$task) {
throw new UserMessageException(t('The task "generate_merchant_feed" is not installed.'));
}
// Remove existing schedule triggers for a single clean entry
$triggerRepo = $this->em->getRepository(OnScheduleTrigger::class);
foreach ($triggerRepo->findBy(['task' => $task]) as $old) {
$this->em->remove($old);
}
$this->em->flush();
if (!$enabled) {
// keep it off the list if disabled; change strategy if you prefer "disabled but visible"
return;
}
// Create “every N minutes” schedule + trigger
$minutes = max(1, (int) $minutes);
$schedule = $this->scheduleFactory->createEveryXMinutes($minutes);
if (method_exists($schedule, 'setDescription')) {
$schedule->setDescription(t('Every %s minute(s) — Google Merchant feed', $minutes));
}
$this->em->persist($schedule);
$trigger = $this->triggerFactory->create($schedule, $task);
if (method_exists($trigger, 'setIsEnabled')) {
$trigger->setIsEnabled(true);
}
$this->em->persist($trigger);
$this->em->flush();
}
}
# 2) Fix your package controller binding
Since we removed `TaskFactory`, update the binding to only inject what we actually use:
// packages/shopping_feed_manager/controller.php
namespace ConcretePackageShoppingFeedManager;
use ConcreteCorePackagePackage;
use ConcretePackageShoppingFeedManagerSchedulerFeedScheduler;
class Controller extends Package
{
protected $pkgHandle = 'shopping_feed_manager';
protected $appVersionRequired = '9.0.0';
protected $pkgVersion = '1.0.0';
public function getPackageAutoloaderRegistries()
{
return ['src' => 'ConcretePackageShoppingFeedManager'];
}
public function on_start()
{
// Bind (optional, Concrete can auto‑resolve too)
$this->app->bind(FeedScheduler::class, function ($app) {
return new FeedScheduler(
$app->make('DoctrineORMEntityManager'),
$app->make(ConcreteCoreAutomationScheduleScheduleFactory::class),
$app->make(ConcreteCoreAutomationTriggerOnScheduleTriggerFactory::class),
);
});
// If you want to sync on every request, keep this (usually better to call after save/install/upgrade)
// $this->app->make(FeedScheduler::class)->syncFromConfig();
}
public function install()
{
$pkg = parent::install();
Config::save('shopping_feed_manager::interval_minutes', 30);
Config::save('shopping_feed_manager::enable_cron', true);
$this->app->make(FeedScheduler::class)->syncFromConfig();
}
public function upgrade()
{
parent::upgrade();
$this->app->make(FeedScheduler::class)->syncFromConfig();
}
}
# 3) After you save settings
From your dashboard controller `save()` (unchanged):
$this->app->make(ConcretePackageShoppingFeedManagerSchedulerFeedScheduler::class)
->syncFromConfig();
# 4) Confirm it shows in Automation → Schedule
With `enable_cron = true`, you should now see a single **On‑Schedule** entry for your task at `/dashboard/system/automation/schedule`. If you want it to remain visible but disabled, don’t delete old triggers; instead set `setIsEnabled(false)` on the existing one.
# 5) Cron runner
Make sure your system cron runs Concrete’s automation:
* * * * * /path/to/php /path/to/concrete/bin/concrete automation:run >/dev/null 2>&1
That’s it — no `TaskFactory` needed, and your schedule should list correctly.
# Quick checklist
1) **File path & name**
– Create the file exactly here (case‑sensitive on Linux):
packages/shopping_feed_manager/src/Scheduler/FeedScheduler.php
– File contents must start with this namespace:
<?php
declare(strict_types=1);
namespace ConcretePackageShoppingFeedManagerScheduler;
use DoctrineORMEntityManager;
use ConcreteCoreAutomationScheduleScheduleFactory;
use ConcreteCoreAutomationTriggerOnScheduleTriggerFactory;
use ConcreteCoreEntityAutomationTask;
use ConcreteCoreEntityAutomationTriggerOnScheduleTrigger;
use ConcreteCoreErrorUserMessageException;
class FeedScheduler
{
public function __construct(
protected EntityManager $em,
protected ScheduleFactory $scheduleFactory,
protected OnScheduleTriggerFactory $triggerFactory
) {}
// ... methods from earlier (syncFromConfig, createOrUpdateSchedule, etc.)
}
– Class name must be `FeedScheduler` and the filename must be `FeedScheduler.php` (exact case).
2) **Package controller autoload registry**
Add this to your package controller (`packages/shopping_feed_manager/controller.php`):
namespace ConcretePackageShoppingFeedManager;
use ConcreteCorePackagePackage;
class Controller extends Package
{
protected $pkgHandle = 'shopping_feed_manager';
protected $appVersionRequired = '9.0.0';
protected $pkgVersion = '1.0.0';
// Make /src PSR-4 map to ConcretePackageShoppingFeedManager
public function getPackageAutoloaderRegistries()
{
return [
'src' => 'ConcretePackageShoppingFeedManager',
];
}
// ...
}
3) **Use the fully qualified class or import it**
In `controller.php`, either import:
use ConcretePackageShoppingFeedManagerSchedulerFeedScheduler;
and call:
$this->app->make(FeedScheduler::class)->syncFromConfig();
OR call with the FQCN as a string (no import needed):
$this->app->make('ConcretePackageShoppingFeedManagerSchedulerFeedScheduler')->syncFromConfig();
4) **Clear caches**
After adding the file/namespace mapping:
– Dashboard → System & Settings → Optimization → Clear Cache, or
– CLI: `php concrete/bin/concrete c5:clear-cache`
5) **Common pitfalls**
– Folder name must match **package handle**: `shopping_feed_manager` (all lower‑case, underscores).
– Namespace must be `ConcretePackageShoppingFeedManager…` (CamelCase of your package class name, not the handle).
– Your package controller’s PHP namespace is `ConcretePackageShoppingFeedManager`, so your `/src` classes must also start with that.
– Linux is case‑sensitive. `src/Scheduler/FeedScheduler.php` ≠ `src/scheduler/feedscheduler.php`.
– If you also have a `composer.json` autoload for the package, ensure it’s not conflicting. (Most packages just use `getPackageAutoloaderRegistries()`—that’s fine.)
6) **Sanity test**
Temporarily add this near the top of `on_start()` to verify autoload:
if (!class_exists(ConcretePackageShoppingFeedManagerSchedulerFeedScheduler::class)) {
Log::addError('FeedScheduler class not found by autoloader.');
}
Then clear cache and reload any page; check Dashboard → Reports → Logs.
—
### Minimal working `on_start()` sample
public function on_start()
{
// (Optional) You can bind, but not required:
// $this->app->bind(ConcretePackageShoppingFeedManagerSchedulerFeedScheduler::class, function ($app) {
// return new ConcretePackageShoppingFeedManagerSchedulerFeedScheduler(
// $app->make('DoctrineORMEntityManager'),
// $app->make(ConcreteCoreAutomationScheduleScheduleFactory::class),
// $app->make(ConcreteCoreAutomationTriggerOnScheduleTriggerFactory::class),
// );
// });
// Try making it (autoload must resolve now)
$this->app
->make('ConcretePackageShoppingFeedManagerSchedulerFeedScheduler')
->syncFromConfig();
}
Do the 6 steps above (especially the autoloader registry + exact file path), clear cache, and the error will go away.
Below is a drop‑in `FeedScheduler` that:
– Uses **Doctrine only** (no `ScheduleFactory`, no `TaskFactory`)
– Creates a schedule entity compatible with what your build exposes (tries multiple known classes)
– Persists a single **On‑Schedule** trigger so it shows under **System → Automation → Schedule**
### `packages/shopping_feed_manager/src/Scheduler/FeedScheduler.php`
<?php
declare(strict_types=1);
namespace ConcretePackageShoppingFeedManagerScheduler;
use DoctrineORMEntityManager;
use ConcreteCoreEntityAutomationTriggerOnScheduleTrigger;
use ConcreteCoreEntityAutomationTask;
use ConcreteCoreErrorUserMessageException;
class FeedScheduler
{
public function __construct(protected EntityManager $em) {}
/** Read current settings */
protected function getSettings(): array
{
$enabled = (bool) Config::get('shopping_feed_manager::enable_cron', true);
$minutes = max(1, (int) Config::get('shopping_feed_manager::interval_minutes', 30));
return ['enabled' => $enabled, 'minutes' => $minutes];
}
/** Call this after install/upgrade/dashboard save */
public function syncFromConfig(): void
{
$s = $this->getSettings();
$this->createOrUpdateSchedule($s['enabled'], $s['minutes']);
}
/**
* Create/Update the on-schedule trigger so it appears in
* /dashboard/system/automation/schedule and actually runs.
*/
public function createOrUpdateSchedule(bool $enabled, int $minutes): void
{
// 1) Find your task
$task = $this->em->getRepository(Task::class)->findOneBy(['handle' => 'generate_merchant_feed']);
if (!$task) {
throw new UserMessageException(t('The task "generate_merchant_feed" is not installed.'));
}
// 2) Keep exactly one trigger for a clean UI
$triggerRepo = $this->em->getRepository(OnScheduleTrigger::class);
foreach ($triggerRepo->findBy(['task' => $task]) as $old) {
$this->em->remove($old);
}
$this->em->flush();
if (!$enabled) {
// If disabled, don't create a trigger (or change to a disabled trigger if you prefer it visible)
return;
}
$minutes = max(1, (int) $minutes);
// 3) Build a schedule entity compatible with your Concrete version
$schedule = $this->makeScheduleEntity($minutes);
// Optional: decorate with a description if supported
foreach (['setDescription', 'setNotes'] as $setter) {
if (method_exists($schedule, $setter)) {
$schedule->{$setter}(t('Every %s minute(s) — Google Merchant feed', $minutes));
break;
}
}
// 4) Persist schedule + trigger
$this->em->persist($schedule);
$trigger = new OnScheduleTrigger();
// These setters exist in all v9 builds:
$trigger->setSchedule($schedule);
$trigger->setTask($task);
if (method_exists($trigger, 'setIsEnabled')) {
$trigger->setIsEnabled(true);
}
$this->em->persist($trigger);
$this->em->flush();
}
/**
* Creates an appropriate Schedule entity depending on what your build provides.
* Tries a few known classes; falls back to a cron-expression style schedule.
*/
protected function makeScheduleEntity(int $minutes): object
{
// a) Every-X-Minutes schedule (preferred if available)
$classEvery = 'ConcreteCoreEntityAutomationScheduleEveryXMinutesSchedule';
if (class_exists($classEvery)) {
$schedule = new $classEvery();
if (method_exists($schedule, 'setMinutes')) {
$schedule->setMinutes($minutes);
return $schedule;
}
}
// b) Cron-like schedule
$classCron = 'ConcreteCoreEntityAutomationScheduleCronSchedule';
if (class_exists($classCron)) {
$schedule = new $classCron();
$expr = "*/{$minutes} * * * *";
foreach (['setCronExpression', 'setExpression'] as $setter) {
if (method_exists($schedule, $setter)) {
$schedule->{$setter}($expr);
return $schedule;
}
}
}
// c) Basic schedule with expression
$classBasic = 'ConcreteCoreEntityAutomationScheduleBasicSchedule';
if (class_exists($classBasic)) {
$schedule = new $classBasic();
$expr = "*/{$minutes} * * * *";
foreach (['setCronExpression', 'setExpression'] as $setter) {
if (method_exists($schedule, $setter)) {
$schedule->{$setter}($expr);
return $schedule;
}
}
}
// If none of the above exist, fail loudly so we notice
throw new UserMessageException(t('No compatible schedule entity found in this Concrete CMS build.'));
}
/** Optional: read back a short description for your UI */
public function getScheduleSummary(): ?string
{
$task = $this->em->getRepository(Task::class)->findOneBy(['handle' => 'generate_merchant_feed']);
if (!$task) {
return null;
}
$trigger = $this->em->getRepository(OnScheduleTrigger::class)->findOneBy(['task' => $task], ['id' => 'DESC']);
if (!$trigger) {
return null;
}
$schedule = $trigger->getSchedule();
if (!$schedule) {
return null;
}
if (method_exists($schedule, 'getDescription')) {
$desc = (string) $schedule->getDescription();
if ($desc) return $desc;
}
if (method_exists($schedule, 'getMinutes')) {
return t('Every %s minute(s)', (int) $schedule->getMinutes());
}
return t('Scheduled');
}
}
### Update your package controller
You no longer need to resolve `ScheduleFactory`. Bind nothing, or just make the class directly:
// packages/shopping_feed_manager/controller.php
namespace ConcretePackageShoppingFeedManager;
use ConcreteCorePackagePackage;
class Controller extends Package
{
protected $pkgHandle = 'shopping_feed_manager';
protected $appVersionRequired = '9.0.0';
protected $pkgVersion = '1.0.0';
public function getPackageAutoloaderRegistries()
{
return ['src' => 'ConcretePackageShoppingFeedManager'];
}
public function install()
{
$pkg = parent::install();
Config::save('shopping_feed_manager::interval_minutes', 30);
Config::save('shopping_feed_manager::enable_cron', true);
// Make the schedule now
$this->app->make(ConcretePackageShoppingFeedManagerSchedulerFeedScheduler::class)->syncFromConfig();
}
public function upgrade()
{
parent::upgrade();
$this->app->make(ConcretePackageShoppingFeedManagerSchedulerFeedScheduler::class)->syncFromConfig();
}
// If you were calling it in on_start() and want to keep that, this works too:
// public function on_start()
// {
// $this->app->make(ConcretePackageShoppingFeedManagerSchedulerFeedScheduler::class)->syncFromConfig();
// }
}
### Don’t forget
– **Clear cache** after adding the class and autoloader mapping:
Dashboard → System & Settings → Optimization → Clear Cache (or `php concrete/bin/concrete c5:clear-cache`)
– Ensure your system cron runs the automation runner:
* * * * * /path/to/php /path/to/concrete/bin/concrete automation:run >/dev/null 2>&1
– With `enable_cron = true`, you should now see your entry under
**/dashboard/system/automation/schedule**.
If your build still errors on the schedule class, tell me your exact Concrete CMS version (e.g., 9.0.2 / 9.1 / 9.2.x) and I’ll tailor the schedule entity detection to that version.