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

Cron Bundle Laravel Package

lingoda/cron-bundle

Lingoda Cron Bundle adds cron job scheduling to Symfony applications, letting you define recurring tasks and run console commands on configured schedules from a single, centralized bundle.

View on GitHub
Deep Wiki
Context7

Getting Started

First, the reality check. lingoda/cron-bundle is a Symfony bundle, not a Laravel bundle. There is no CronServiceProvider, no config/cron.php, and no facade. You cannot composer require lingoda/cron-bundle into a Laravel app and expect anything to happen. So "getting started" means deciding which of three entry points you're taking:

  1. Reference implementation — read it to copy the attribute/annotation-driven job-registration pattern into your own Laravel scheduler.
  2. Sidecar — run a small Symfony application alongside your Laravel app that only holds cron job definitions, dispatching into Laravel via HTTP endpoints or a shared queue.
  3. Fork/port — vendor the job-discovery and lock logic into app/Cron/ and drive it from Laravel's Kernel::schedule().

Where to look first, in the repo:

  • src/ — find the attribute/annotation class (likely Attribute/Cron.php or Annotation/Cron.php). That class's constructor arguments are the package's API surface: cron expression, job name, lock behaviour, timezone.
  • src/DependencyInjection/ — the extension class tells you how jobs are discovered (almost certainly a compiler pass that scans for services tagged via the attribute). This is the part you'd reimplement in Laravel as a container scan in a service provider.
  • src/Command/ — the console command that system cron invokes. Read its exit codes and flags; that's your operational contract. The 2.3.1 fix ("prevent new scheduled jobs from firing immediately on deploy") lives in or near this loop — find the branch that decides whether a job with no prior run record is due, because that is the exact code you'd otherwise reimplement wrong.
  • Resources/config/services.yaml (or config/services.php) — the tag names and service IDs you can override.
  • README.md — with 1 star and 0 dependents, the README is likely the only documentation, and likely written for Lingoda's internal conventions. Check whether it documents the new-job behaviour; a patch release note is often the only changelog entry you'll get.

First use case: a single job class that answers "does this run every five minutes, and can I see it ran?" Implement one job, wire it to one system crontab line (* * * * * php bin/console <bundle-command>), and confirm the run is logged and locked before adding a second job. Then add a second job whose expression is a few minutes in the future and confirm it does not fire on registration — that's the behaviour 2.3.1 guarantees, and the one most likely to regress if you fork.

Implementation Patterns

Attribute-per-job as the unit of work. The idiomatic shape in Symfony cron bundles is one service class per job, annotated with the schedule:

#[Cron('*/5 * * * *', name: 'sync-prices')]
class SyncPricesJob { public function __invoke(): void { /* ... */ } }

If you port this to Laravel, keep the shape and swap the plumbing: a CronJob interface with expression() and handle(), an AppServiceProvider that scans app/Cron/, and a registrar that calls $schedule->call($job)->cron($job->expression()) per discovered class. You get the bundle's ergonomics — schedule lives next to the code it schedules, not in a 200-line Kernel.php — without the Symfony runtime. If you port, port the 2.3.1 semantics too: "no last-run recorded" must mean "not due yet", not "due now".

One tick, many jobs. The bundle's core operational pattern is a single system crontab entry that fires every minute; the command then evaluates all registered jobs and dispatches only the due ones. This is strictly better than one crontab line per job in Laravel too: you get a single log stream, a single lock domain, and jobs become code rather than server config. If you're running Laravel anywhere with more than ~10 scheduled tasks, mirror this — $schedule->command('cron:run')->everyMinute() plus your own due-job dispatcher.

Overlap protection is the real feature. The reason to read this bundle rather than just use ->everyFiveMinutes() is the locking. Look for symfony/lock usage or flock() against a lock file per job name. The pattern to steal: acquire a non-blocking lock keyed on the job name before execution, skip (log, don't throw) if held. In Laravel, Cache::lock("cron:{$name}", $ttl)->get() inside the job, or ->withoutOverlapping() on the schedule entry — but note withoutOverlapping() mutes silently, so add your own onOverlap logging.

Bridging into Laravel work. If you take the sidecar route, the cleanest integration is: Symfony job → Http::post(config('app.url').'/internal/jobs/sync-prices', ...) with a shared-secret middleware, or → push a job class name onto a Redis queue Laravel consumes. The HTTP route is simpler to debug (you can curl it); the queue route gives you retries and backoff for free. Do not have the sidecar share your Laravel database models — keep the boundary at "trigger this named job."

Deploy as a first-class workflow. Since 2.3.1, shipping a new job definition is a deploy event with defined semantics: the job is registered, marked as having no prior run, and waits for its next real occurrence. That makes blue/green and rolling deploys safe — you can ship job code at any hour without a burst of unexpected executions across every box. It also means your "run it now" escape hatch has to be explicit: a console command or an admin action that dispatches the job synchronously, rather than relying on registration to trigger it.

Testing. Freeze the clock (Carbon::setTestNow()) and assert the expression evaluator, not the command. The two bug classes to guard: off-by-one in "next run" calculation and timezone drift — both pure functions. Add a third test that asserts a job with an empty run history is not due, which is the regression 2.3.1 fixes.

Gotchas and Tips

New jobs used to fire on deploy — verify you're on 2.3.1 or later. Before this patch, a newly discovered job with no recorded last-run was treated as due, so every deploy that added job definitions produced an immediate, unscheduled burst — often across every app server at once. Confirm the installed version, and if you forked the due-check logic (see entry point 3), diff your copy against the fix; the bug is easy to reintroduce because "no last run means run now" looks harmless in a fresh implementation. Conversely, if anything in your process depended on that accidental behaviour — a one-off job added to prod and expected to fire within the minute — it now won't until its expression next matches.

Autoconfiguration is load-bearing. Symfony attribute-based job discovery requires autoconfigure: true on the service definition, or the compiler pass won't see your job. Porting to Laravel, the equivalent trap is class discovery: glob() over app/Cron/ misses subdirectories and breaks under opcache.preload if you're not careful. Use Composer's classmap or an explicit config('cron.jobs') array rather than filesystem scanning — it's one more line and it survives refactors.

Timezone is the #1 silent failure. Cron expressions are evaluated in some timezone — usually the container's, sometimes the app's, occasionally a per-job override. A job declared 0 9 * * * intending 09:00 Europe/Berlin will fire at 09:00 UTC if the container is UTC, which is 10:00 or 11:00 for your users depending on DST. Set date_default_timezone_set() explicitly at the entry point, or pass a timezone per job, and write a test that asserts a DST-boundary date.

"Missed run" semantics are still undefined — the patch only covers new jobs. 2.3.1 settles the "never run before" case; it says nothing about "should have run 20 minutes ago while the tick command was down." Most implementations skip (each tick only asks "is it due now"). That's usually right for sync jobs and usually wrong for billing. Read the command's loop before you rely on it, and add an explicit catch-up path for anything where a missed run matters. Note that the new-job logic may share a code path with catch-up, so re-read that branch after every minor bump.

Long jobs vs. a one-minute tick. A job that takes 90 seconds will still show as "due" on the next tick. The lock protects you, but only if the lock TTL exceeds the worst-case runtime. Set TTLs generously (or use a heartbeat-renewed lock); a too-short TTL silently allows concurrent execution of the exact job you were trying to protect.

Pin it, and expect to fork it. One star, zero dependents, and a single-maintainer bus factor means this is effectively vendored code. Pin an exact version ("lingoda/cron-bundle": "2.3.1", not ^2.3), and read composer.lock diffs on update. A patch-level behaviour change like this one is a good reminder that ^ ranges on a low-traffic package hand you whatever the maintainer pushed that afternoon. Verify what's actually on Packagist before trusting version constraints. Budget a day to fork into app/Cron/ if the Symfony coupling becomes friction; the useful surface area is small (attribute + compiler pass + lock + evaluator) and porting it removes an entire runtime from your deployment.

Extension points worth knowing. Job-run listeners (success/failure events) and the lock store are the two seams that matter. The first gives you Slack alerting on failure without touching job code; the second lets you move locking to Redis when you scale past one host. If the bundle exposes neither, that's a strong argument for the fork.

Debugging a job that "didn't run." Work the ladder in this order: (1) is the system crontab line present and did it fire — check the tick command's own log; (2) is the job registered — the compiler pass / discovery is silent when it misses; (3) is the expression valid — malformed cron expressions often evaluate to "never" rather than erroring; (4) is a stale lock held from a previously killed process; (5) since 2.3.1, was the job added after its last scheduled time today — a job added at 10:00 with 0 9 * * * correctly waits until tomorrow, which reads as "didn't run" if you expected deploy-time execution. Steps 2, 4, and 5 account for most incidents.

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.
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums
rlacerda83/query-parser
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings