Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Console Command Scheduler Bundle Laravel Package

bytespin/console-command-scheduler-bundle

ByteSpin Console Command Scheduler is a Symfony 6.3+ bundle for scheduling console commands via Messenger Scheduler, logging execution time, duration and return codes to database and log files, with an EasyAdmin interface and extendable email notifications.

View on GitHub
Deep Wiki
Context7

Getting Started

Scope check first: this is a Symfony 6.3+ bundle, not a Laravel package. There is no Laravel service provider, no Kernel::schedule() integration, and no composer auto-discovery. If you are working in a Laravel codebase you cannot install it — the equivalent job is done by Schedule::command() in routes/console.php. The rest of this assessment assumes you are in (or consulting on) a Symfony app, and evaluates it the way a Laravel dev would: as "task scheduler + run log + admin UI + failure hooks".

What it actually is: a thin, DB-driven wrapper around symfony/scheduler + symfony/messenger. You get a Scheduler entity (one row per scheduled command), a SchedulerLog entity, EasyAdmin CRUD screens for both, a file logger, and a handful of events. The scheduling itself is still Symfony's RecurringMessage — the bundle just builds triggers from database rows instead of PHP attributes.

Minimum path to a first running task (~15 minutes):

composer require bytespin/console-command-scheduler-bundle
// config/bundles.php — manual registration, no Flex recipe
ByteSpin\ConsoleCommandSchedulerBundle\ConsoleCommandSchedulerBundle::class => ['all' => true],
# wires the vendor entity mapping into your entity manager
php bin/console bytespin:configure-console-command-scheduler

# no migrations ship with the bundle — schema:update is the documented path
php bin/console doctrine:schema:update --force

Then make sure you have an EasyAdmin dashboard (bin/console make:admin:dashboard if not) and add the menu entries:

yield MenuItem::subMenu('Symfony Scheduler', 'fa-duotone fa-folder-gear')->setSubItems([
    MenuItem::linkToCrud('Scheduled Tasks', 'fa-light fa-clock', Scheduler::class)
        ->setController(SchedulerCrudController::class),
    MenuItem::linkToCrud('Logs', 'fa-duotone fa-clock-rotate-left', SchedulerLog::class)
        ->setController(SchedulerLogCrudController::class),
]);

Create one entry in the admin (app:my-command, Type = Frequency, Frequency = 10 seconds), then run the consumer in a terminal:

# verify the transport name first — it is scheduler_<scheduleName>
php bin/console debug:messenger | grep -i scheduler
php bin/console messenger:consume scheduler_scheduler -vv

Where to look first in the codebase, in order: Entity/Scheduler.php (the fields are the feature list), Controller/Admin/SchedulerCrudController.php (how the command choices and messenger queues are populated), the message handler that runs Process/Application::run, then Event/. Everything else is Symfony plumbing.

The one first use case that justifies the bundle: an ETL/nightly import job that non-developers need to enable/disable, reschedule, and inspect failures on — without a deploy. That is exactly what it was built for (the README says so), and it is the only scenario where it beats a #[AsSchedule] provider class.

Implementation Patterns

Cron vs Frequency — pick deliberately. Type: Cron produces RecurringMessage::cron($expr, $msg); Type: Frequency produces RecurringMessage::every($freq, $msg) and additionally honours from_date/from_time/until_date/until_time (those four are silently ignored for Cron). Rule of thumb:

Need Type
"every 5 minutes", "first monday of next month" Frequency
"weekdays at 02:00", "1st of month" Cron
A fixed maintenance window (start/stop dates) Frequency (Cron can't express it)

Isolate slow jobs on their own messenger queue. This is the highest-value field in the form. Long-running reports on the default scheduler queue block every other due task. Create a transport, select it in the "Messenger queue" dropdown, and run a second supervisor program:

# config/packages/messenger.yaml
framework:
    messenger:
        transports:
            scheduler_reports:
                dsn: 'doctrine://default'
                options: { queue_name: scheduler_reports }
; supervisor: one worker per queue
command=php bin/console messenger:consume scheduler_scheduler --time-limit=3600
command=php bin/console messenger:consume scheduler_reports  --time-limit=3600

Wire the three lifecycle events into your notifier. This is the extension point most teams actually use:

use ByteSpin\ConsoleCommandSchedulerBundle\Event\SchedulerCommandFailureEvent;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;

#[AsEventListener(event: 'bytespin.failure.scheduled.console.command')]
final class CommandFailureNotifier
{
    public function __construct(private Slack $slack) {}

    public function __invoke(object $event): void
    {
        // inspect the event's command name, arguments, return code, duration
        $this->slack->alert(sprintf('Scheduled command failed (%s)', get_class($event)));
    }
}

Check the concrete event classes in src/Event before typing the listener — the README lists the string names (bytespin.before|success|failure.scheduled.console.command) but not the accessor methods, and the generic object signature above is deliberately loose until you read them.

Handle skipped/invalid entries separately from failures. Since 2.1 these are two different signals: a failure means the command ran and exited non-zero; a fault means the entry never ran at all (bad cron, deleted command, unparsable frequency). Route SchedulerEntryFaultedEvent to a lower-urgency channel that a human fixes, and failures to on-call.

#[AsEventListener(event: SchedulerEntryFaultedEvent::NAME)]
final class SchedulerFaultListener
{
    public function __invoke(SchedulerEntryFaultedEvent $event): void
    {
        // $event->entry : Scheduler entity (id, command, frequency)
        // $event->error : string reason
    }
}

Gate every deploy on lint. This is the CI pattern the bundle is missing a recipe for:

- run: php bin/console bytespin:scheduler:lint   # non-zero exit if any enabled entry can't run
- run: php bin/console doctrine:migrations:migrate --no-interaction
- run: php bin/console cache:clear
- run: php bin/console messenger:stop-workers    # workers pick up new code

Use no_db_log for high-frequency entries. An every-10-seconds health probe writes ~8,600 rows/day and will bloat scheduler_log and slow the EasyAdmin log list. File logging continues regardless; keep DB logs for tasks a human would investigate.

Arguments are a raw space-split string. Treat that field as a shell-adjacent hazard: there is no quoting contract, so --env=prod --ids=1,2,3 is fine but --name="My Report" is not something you should rely on. The durable pattern is to keep the argument field empty and put a thin, no-arg wrapper command in your app:

#[AsCommand(name: 'app:nightly-report')]
class NightlyReportCommand extends Command
{
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        // all real parameters live here, in code, under review
        $this->report->run(env: $this->params->get('kernel.environment'));
        return Command::SUCCESS;
    }
}

The same wrapper is where you add mutual-exclusion, which the bundle does not provide:

$lock = $this->lockFactory->createLock('nightly-report', 3600);
if (!$lock->acquire()) {
    return Command::SUCCESS; // previous run still going; not an error
}
try { /* ... */ } finally { $lock->release(); }

Tags (2.1+, opt-in) are worth enabling once you exceed roughly a dozen entries — "billing", "etl", "cleanup" — purely as an admin-list filter. It costs you a Tag entity implementing TagInterface, a resolve_target_entities mapping, and a schema update. Skip it for a five-task install.

Local development loop: create the entry with Frequency = 10 seconds, leave Disabled unchecked, and run messenger:consume scheduler_scheduler -vv in a foreground terminal. You get the trigger log, the command output, and a new row in the log CRUD in one pass — much faster than reasoning about cron.

Gotchas and Tips

  • The README's install command contains a typo. It says bytespin/console-commande-scheduler-bundle (extra e in commande); the repository and package are console-command-scheduler-bundle. If composer require 404s, that's why.
  • No Flex recipe, no migrations, no auto-registration. Three manual steps (bundles.php, entity-manager mapping, schema) and a re-run of doctrine:schema:update --force every time you upgrade. For a real project, don't use schema:update in production — after upgrading the vendor dir, run doctrine:migrations:diff once, review the generated migration, and commit it. The bundle's own entity mapping lives under vendor/, so your migration is the only record of the schema change.
  • auto_mapping: true silently breaks multi-entity-manager setups. The bundle resolves the EM via getManagerForClass(), which returns the wrong manager (or null) under auto-mapping. Remove the key and map everything explicitly, or you'll get "The class ... was not found in the chain configured namespaces" at runtime, not at boot.
  • The classic cron-in-the-frequency-field trap. Typing 0 0 * * 1-5 into Frequency used to crash the scheduler worker and take every other task down with it. 2.1 adds a class-level constraint rejecting this in the form, plus fault isolation at schedule-build time — but only if you're on 2.1+. On older versions, one bad row can stall the whole schedule.
  • Missed-run semantics changed in 2.1. After downtime the scheduler now fires at most one catch-up occurrence per task instead of replaying everything. If you have a task that legitimately depends on every missed tick (rollups, ledger reconciliation), set process_only_last_missed_run: false under scheduler: — but understand you are opting back into thundering-herd behaviour after a long outage.
  • Verify the worker transport name, don't copy it. The README says messenger:consume scheduler_scheduler, which assumes the bundle names its schedule scheduler. If you have your own #[AsSchedule('default')] you'll also have scheduler_default. bin/console debug:messenger is the source of truth; a wrong transport name means your commands silently never run and there is no error to find.
  • Workers must be restarted after every deploy. Messenger caches code in long-lived PHP processes. Without messenger:stop-workers (or a supervisor restart), new code — and new command definitions the CRUD listing depends on — won't appear. This applies to the command list dropdown too: a newly added command may not show until the cache is cleared and the worker restarted.
  • The command dropdown is only as good as the running container. It enumerates console.command tagged services. Commands registered conditionally (per-environment, or by a bundle enabled only in dev) will be missing from production's list, and a command removed after being scheduled becomes a faulted entry rather than a hard error — hence the lint command in CI.
  • "Log File" is a filename, not a path. Only the basename is honoured; the default is %env%_scheduler.log under your log dir. Two entries pointing at the same filename interleave output. Use one filename per task if you intend to tail one while debugging.
  • Fault listeners must not throw. SchedulerEntryFaultedEvent is dispatched during schedule construction. An exception in your listener turns a single skipped task into a broken worker — the opposite of the fault isolation the feature exists to provide. Wrap external calls (HTTP, mail) in try/catch and log-and-continue.
  • Timezone. Triggers are built from cron expressions and relative frequency strings evaluated in PHP's default timezone. "0 2 * * *" is 02:00 in date.timezone, not your users' locale. Set it explicitly in php.ini/framework config, and remember DST will shift or skip one occurrence twice a year — don't schedule anything date-critical within an hour of a DST boundary.
  • Tighten the admin surface. SchedulerCrudController exposes arbitrary command execution to anyone who can reach that EasyAdmin route. Gate the menu and the controllers with ROLE_ADMIN/a dedicated ROLE_SCHEDULER_ADMIN, and confirm SchedulerCrudController isn't reachable on a public host.
  • Maturity: treat it as vendored internal code. Beta, single-author, zero stars/dependents, no recipe, no migrations, schema drift across versions (1.0.13 and 1.1.0 both broke schema). Pin the exact version in composer.json, keep a fork in mind, and read CHANGELOG before every bump. The upside: the surface is small and mostly Symfony's own scheduler, so replacing it with a hand-rolled #[AsSchedule] provider plus a schedule_command table is a weekend, not a quarter — keep the logging and the event hooks portable by listening on your own domain events rather than on the bundle's.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
ably/pubsub-server
mstfkhazaal/defender
mfonte/base62x
karnoweb/metapilot
tobya/bcsapiwrapper
swalker2/cpanel
vish4395/laravel-translation-manager
morningtrain/economic
teksite/systeminfo-laravel
atomcms/voting
run6/weather
siokas/laravelembeddirectives
adamlien/pkuuids
gilsonsouza/lara-doc-generator
manelgavalda/enrollment_mobile
unabomber/timezone
delatbabel/contacts
astritzeqiri/g-recaptcha
kevinongko/laravel-genode
developeruz/laravel-analytics