Scheduled tasks¶
Status: Implemented. The public API targets Shopware 6.6 and 6.7 while delegating execution and persistence to Shopware's scheduled-task runtime.
Frosh Jetpack reduces a Shopware scheduled task from a message class plus a handler class to one ordinary, autowired service. Shopware remains responsible for persistence, scheduling, queueing, status transitions, retries, CLI commands, and plugin lifecycle registration.
Consumer API¶
Create the one-file starting point with:
The maker uses an explicit stable task name and supports dry runs. See the scaffolding guide for all options and file-safety guarantees.
<?php declare(strict_types=1);
namespace Acme\Review\ScheduledTask;
use Frosh\Jetpack\Attribute\AsScheduledTask;
use Shopware\Core\Framework\Context;
#[AsScheduledTask(interval: 300)]
final readonly class CleanupExpiredReviews
{
public function __construct(private ReviewCleaner $cleaner)
{
}
public function __invoke(Context $context): void
{
$this->cleaner->cleanup($context);
}
}
The class is a normal Symfony service, so constructor injection works as usual. It does not extend a
Shopware or Jetpack base class and the plugin keeps extending Shopware's regular Plugin class. When
the consumer namespace is already loaded through an autowired and autoconfigured service resource,
the PHP class is the only task-specific file or service declaration.
The public attribute is deliberately small:
namespace Frosh\Jetpack\Attribute;
#[\Attribute(\Attribute::TARGET_CLASS)]
final readonly class AsScheduledTask
{
public function __construct(
public int $interval,
public ?string $name = null,
public bool $rescheduleOnFailure = false,
) {
}
}
intervalis the default interval in seconds and must be at least1.nameoverrides the task's operational name. When omitted, Jetpack derives a deterministic name from the fully qualified service class by converting namespace separators to dots and CamelCase to snake case. The example becomesacme.review.scheduled_task.cleanup_expired_reviews.rescheduleOnFailuremaps directly to Shopware's scheduled-task failure behavior. The default isfalse, which marks the task failed and lets the exception reach Messenger.truelogs the exception and schedules the next execution using Shopware's normal behavior.
The attributed service must be concrete and expose exactly:
Jetpack creates a fresh Context::createCLIContext() for every execution. A sales-channel context is
not constructed. Dependencies and task inputs belong in constructor-injected services or persistent
storage, not in the scheduled message.
An explicit name is recommended when external operations call Shopware's task commands by name or when the PHP class may be renamed:
#[AsScheduledTask(
interval: 300,
name: 'acme_review.cleanup_expired_reviews',
rescheduleOnFailure: true,
)]
Names must contain at least two vendor-prefixed dot-separated segments, be no longer than 255
characters, and match ^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)+$. Effective names must be unique
across Jetpack scheduled tasks and must not collide with a resolvable native Shopware task name.
Why Jetpack needs internal adapters¶
Shopware intentionally has two classes for one scheduled task:
- A message extends
ScheduledTask. Its constructor is final and empty, and its static methods provide the name, interval, and failure policy. - A handler extends
ScheduledTaskHandler. It receives dependencies and owns Shopware's running, failed, and rescheduled state transitions.
One consumer class cannot extend both inheritance trees. Reusing one generic message for all
attributed tasks also does not work because Shopware's scheduled_task table has a unique constraint
on scheduled_task_class. Replacing Shopware's scheduler with a Jetpack table would lose its status,
CLI, lifecycle, queue, and merchant interval behavior.
Jetpack therefore keeps the two core roles internally while exposing only the attributed service to consumers.
Internal architecture¶
CleanupExpiredReviews (consumer service)
│
│ invoked with a CLI Context
▼
AttributedScheduledTaskHandler (one internal service definition per task)
▲
│ Messenger handles
│
Generated ScheduledTask proxy (one deterministic class per task)
▲
│ registered, queued, and persisted by
│
Shopware TaskRegistry and TaskScheduler
Discovery¶
FroshJetpack::build() registers an internal compiler pass. The pass inspects registered Symfony
services for #[AsScheduledTask]; it does not scan arbitrary PHP files. This keeps service ownership
and constructor injection consistent with Symfony.
For every attributed service, the pass creates an immutable internal descriptor containing:
- the consumer service ID and class;
- the effective name and default interval;
- the failure policy;
- a deterministic generated message class name;
- the reflected source file, used to resolve the owning Shopware bundle.
Invalid declarations fail container compilation with the consumer class and property in the error. This avoids booting a system whose scheduler cannot dispatch a declared task.
Generated message proxy¶
Jetpack generates one small PHP class per descriptor into the Symfony cache. Each class extends
Shopware's ScheduledTask and implements only:
getTaskName();getDefaultInterval();shouldRescheduleOnFailure().
The generated class identity is a stable hash of the consumer class and effective task name. The interval and failure flag are not part of the identity. Consequently:
- changing the interval updates Shopware's default while preserving a merchant-modified interval;
- changing the failure policy updates the same registered task;
- changing the class or effective name removes the old core task and registers a new one, resetting its schedule and any merchant interval override.
Generated source uses fixed templates plus var_export() values; no consumer expression is
evaluated. The proxy methods read an internal metadata map that is replaced whenever the generated
file is loaded. This matters when Shopware rebuilds its kernel in the same PHP process: the stable PHP
class remains loaded, while a changed interval or failure policy still takes effect immediately.
An aggregate generated file is written atomically below %kernel.cache_dir% and loaded during Frosh
Jetpack bundle boot so scheduler and worker processes can resolve the persisted proxy class before
instantiating it. The generated namespace and class names are internal implementation details and
must not be referenced by consumers.
Service wiring¶
The compiler pass registers two internal service definitions per descriptor:
- The generated proxy is tagged
shopware.scheduled.task. - An
AttributedScheduledTaskHandlerextends Shopware'sScheduledTaskHandler, receivesscheduled_task.repository,logger, and the consumer service, and is taggedmessenger.message_handlerwith the generated proxy as its explicithandlesvalue.
The adapter's run() method calls the consumer service with a fresh CLI Context. Extending the core
handler is important: it leaves duplicate-execution protection, running/failed states, exception
logging, retry behavior, and rescheduling in Shopware.
The handler constructor always passes both repository and logger to its parent. That is compatible
with Shopware 6.6, where the logger was optional, and Shopware 6.7, where it is required. The proxy
uses only the ScheduledTask static contract shared by both versions.
Lifecycle¶
Jetpack does not add another lifecycle subscriber or database table. Because every generated proxy
uses the normal shopware.scheduled.task tag, Shopware's existing lifecycle subscriber registers and
removes it on plugin activation, update, and deactivation. The normal commands continue to work:
bin/console scheduled-task:register
bin/console scheduled-task:list
bin/console scheduled-task:run-single acme_review.cleanup_expired_reviews
The consumer plugin does not call Jetpack from install(), update(), activate(), or
deactivate().
Validation and diagnostics¶
The unified command includes a Scheduled tasks row:
It reports the number of attributed tasks owned by the selected bundle. Declaration validation runs during container compilation and checks:
- a concrete, registered service class with exactly one non-repeatable attribute;
- a public
__invoke(Context $context): voidmethod; - a positive interval;
- the name syntax and 255-character limit;
- unique effective names, including collisions with resolvable native Shopware task metadata;
- a resolvable source file used for bundle ownership;
- deterministic, syntactically valid generated proxy source.
An invalid declaration prevents the container from compiling because it cannot safely be registered. The command remains useful in CI for bundle-scoped inventory and one consolidated Jetpack report. Neither validation path accesses or modifies the database.
Diagnostics use the prefix [Frosh Jetpack Scheduled Task] and name the consumer class, for example:
Public and internal surface¶
The compatibility promise consists of:
Frosh\Jetpack\Attribute\AsScheduledTaskand its constructor arguments;- the
public function __invoke(Context $context): voidconsumer contract; - the effective-name derivation and interval/failure semantics documented here.
The descriptor registry, compiler pass, source generator, runtime metadata map, generated proxy classes, handler adapter, cache paths, service IDs, and tags created by Jetpack are internal. Shopware's existing task names, table schema, APIs, and commands remain outside Jetpack's public surface.
Automated coverage¶
The test suite covers:
- Attribute defaults and named arguments.
- Deterministic name normalization, validation, Jetpack collisions, and native-task collisions.
- Declaration validation for interval, class shape, and
__invoke()signature. - Stable proxy identity across interval/failure changes and a new identity after a name change.
- Same-process metadata refresh while a stable generated proxy class is already loaded.
- Generated source loading and static method values.
- Compiler-pass service definitions and exact Shopware/Messenger tags.
- Handler adapter forwarding one CLI
Contextto the consumer service. - Sorted registry inventory and bundle ownership filtering.
- The scheduled-task row in the unified validation command.
The adapter deliberately calls only the ScheduledTask, ScheduledTaskHandler, task tag, Messenger
tag, repository, logger, and CLI-context contracts shared by Shopware 6.6 and 6.7. A release matrix
should additionally exercise a small consumer plugin on the latest supported 6.6 and 6.7 minors:
- A consumer plugin contains one attributed, constructor-injected service and no message/handler pair.
- Container compilation generates and loads its proxy in a separate PHP process.
- Plugin activation and
scheduled-task:registercreate the expected core row. - The queued proxy reaches only its matching consumer service.
- Successful execution reschedules through Shopware.
- Failure marks the task failed when
rescheduleOnFailureis false and reschedules it when true. - An interval update changes an untouched default but preserves a merchant-modified run interval.
- Plugin deactivation removes the generated task through Shopware's lifecycle behavior.
- The unified validation command reports all and bundle-scoped task counts.
Deliberate boundaries¶
Dynamic enable predicates, cron expressions, overlap policies beyond Shopware's own state machine, per-sales-channel fan-out, and synchronous execution are intentionally deferred until there is a concrete use case.