goksagun/scheduler-bundle
A Symfony bundle for defining and running scheduled tasks in PHP. Register recurring jobs with simple configuration, then let the scheduler handle execution so you can keep cron logic inside your application instead of scattered server crontabs.
Reality check first: goksagun/scheduler-bundle is a Symfony bundle, not a Laravel package. There is no service provider, no config/scheduler.php, no facade. If you are working in a Laravel codebase, your day-to-day tool is already Illuminate\Console\Scheduling\Schedule (routes/console.php or app/Console/Kernel.php), and pulling this in buys you nothing. The realistic scenarios where you touch it are:
goksagun/scheduler core underneath this bundle.Minimal steps (Symfony context):
composer require goksagun/scheduler-bundle
Register it in config/bundles.php:
return [
// ...
Goksagun\SchedulerBundle\SchedulerBundle::class => ['all' => true],
];
Where to look first — in this order, because the docs are thin at 7 stars / 0 dependents:
src/Attribute/ — the scheduling attribute(s). This is the whole public API surface.src/Command/ — the console command that evaluates and dispatches due tasks. Its name and options tell you how the bundle actually ticks.src/ service definitions (DependencyInjection/) — how tasks are collected from the container.First use case: annotate an existing console command with the schedule attribute instead of writing a crontab line.
#[Scheduled(cron: '*/5 * * * *')]
class SyncInventoryCommand extends Command
{
protected static $defaultName = 'app:sync-inventory';
// ...
}
Then wire the bundle's dispatch command into system cron at one-minute granularity — the bundle decides which annotated tasks are due on each tick:
* * * * * cd /var/www/app && bin/console goksagun:scheduler:run >> /var/log/scheduler.log 2>&1
(The exact command name is in src/Command/ — confirm it before writing the crontab, don't trust a blog post.)
Keep commands thin, put logic in services. The attribute binds scheduling to a console command. Do not put business logic in the command — it makes the task untestable and un-reusable from a queue or HTTP trigger. Pattern:
#[Scheduled(cron: '0 * * * *')]
class HourlyReportCommand extends Command
{
public function __construct(private ReportGenerator $generator) { parent::__construct(); }
protected function execute(InputInterface $in, OutputInterface $out): int
{
$this->generator->run();
return Command::SUCCESS;
}
}
Centralize schedule definitions when they multiply. Attribute-per-command is fine for a handful of jobs. Past ~10, you lose the ability to see the whole timetable, and "why did this run at 3am?" becomes a grep exercise. At that point add a bin/console listing/inspection step to CI that dumps all annotated tasks and their cron expressions, and diff it in review.
Idempotency over mutexes. This bundle is a thin dispatcher; assume there is no withoutOverlapping() / onOneServer() equivalent out of the box. Design every task so a duplicate run is harmless:
UPDATE ... WHERE status = 'pending' claim-and-process rather than read-then-write.SELECT ... FOR UPDATE SKIP LOCKED) if you must have single execution.If you genuinely need mutual exclusion, add it yourself in the command:
$lock = $this->lockFactory->createLock('app:sync-inventory', 300);
if (!$lock->acquire()) { return Command::SUCCESS; } // previous run still going
try { $this->generator->run(); } finally { $lock->release(); }
Testing pattern. Because the task is a plain command, test the service directly and test the binding separately:
public function testInventorySyncIsScheduledEveryFiveMinutes(): void
{
$attr = (new \ReflectionClass(SyncInventoryCommand::class))
->getAttributes(Scheduled::class)[0]->newInstance();
$this->assertSame('*/5 * * * *', $attr->cron);
}
That one assertion catches the most common production bug in this class of package: someone edits the cron string and silently changes the cadence.
Integration tips for a Laravel dev reading a Symfony codebase: map the concepts, don't fight them.
| Laravel | This bundle |
|---|---|
$schedule->command('x')->everyFiveMinutes() |
#[Scheduled(cron: '*/5 * * * *')] on the command |
->withoutOverlapping() |
do it yourself (lock) |
->onOneServer() |
do it yourself (shared cache/DB lock) |
->timezone('UTC') |
likely unsupported — normalize inside the task |
schedule:run in cron |
the bundle's run command in cron |
schedule:list |
check whether the bundle ships one; if not, write it |
Logging is not optional. The dispatcher's decision ("no tasks due", "task X exited 0") is invisible unless you capture it. Point the cron redirect at a log file and also $out->writeln() a line per task with a correlation ID at the start of execute().
Don't try to shoehorn it into Laravel. No service provider, no auto-discovery, no Schedule facade. You would be hand-wiring a Symfony Kernel-adjacent bundle into Laravel's container for a feature Laravel already ships. If a teammate suggests it, the answer is "use routes/console.php".
Adoption risk is the headline gotcha. 7 stars, 0 dependents means: no StackOverflow corpus, no third-party blog posts, and no crowd-sourced bug reports. Consequences:
"goksagun/scheduler-bundle": "1.x.y"), not ^1.0. Unattended minor bumps in a low-adoption package are how you get a 2am pager alert.Attribute discovery is container-cached. In Symfony, adding an attribute to a command is a code change the compiled container may not see. After adding or editing a #[Scheduled], always:
bin/console cache:clear
A "my new task never runs" bug is cache 90% of the time, cron 9%, attribute typo 1%.
Cron granularity gotcha. The dispatcher runs every minute; a task whose runtime exceeds its interval will pile up. */1 * * * * with a 90-second job means two overlapping runs before the first finishes. Either lengthen the interval, add a lock, or make the job resumable.
Timezone. Server clock, PHP date.timezone, and the business's "midnight" are three different things. If the bundle does not expose a per-task timezone, convert inside the task and store timestamps in UTC. Never rely on the box being UTC because it isn't on your laptop.
Debugging order of operations:
bin/console debug:container --tag=... and confirm the command is registered at all.-vvv; if it does nothing, the attribute is not being collected.grep CRON /var/log/syslog and confirm the entry actually fires.Extension points to know before you fork: the command itself (override/subclass if the cron runner's options are insufficient), the task-collection mechanism in the DI extension (to register tasks programmatically rather than by attribute — useful for multi-tenant apps where jobs are per-tenant and can't be expressed as static attributes), and the Symfony event dispatcher if the bundle emits pre/post-run events. Check src/Event/ for the latter; if it exists, hook logging and metrics there rather than sprinkling $out->writeln() everywhere.
The pragmatic verdict for a Laravel shop: treat this as documentation for reading Symfony scheduler code, not as a dependency. If you need framework-agnostic scheduling in a shared package, depend on the goksagun/scheduler core only, wrap it in your own Laravel service provider, and keep the Symfony bundle out of your composer.json entirely.
How can I help you explore Laravel packages today?