mybuilder/cronos-bundle
Symfony bundle that integrates the Cronos cron expression library, making it easy to parse, validate, and evaluate cron schedules for scheduled jobs and time-based tasks in PHP applications.
Read the package name carefully before you composer require. mybuilder/cronos-bundle is a Symfony bundle. There is no bundles.php in a Laravel app, no RegisterBundles step, and no autowiring alias for Cron\CronExpression — the bundle's entire value is container wiring, and that wiring does not exist in Laravel. What you can adopt is the underlying library, mybuilder/cronos (Cron\CronExpression), wired up yourself in a service provider. Budget this as "borrow the parser, write ten lines of glue," not "install a Laravel package."
Minimal setup:
composer require mybuilder/cronos
// app/Providers/AppServiceProvider.php
public function register(): void
{
$this->app->singleton(CronEvaluator::class, fn () => new CronEvaluator());
}
Look at the mybuilder/cronos README (not the bundle's), and specifically the four methods you will use daily:
CronExpression::factory(string $expr) — parseCronExpression::isValidExpression(string $expr) — validate->getNextRunDate($now, $nth, $allowCurrentDate, $tz) — preview/schedule->isDue($now, $tz) / ->getPreviousRunDate(...) — fire decisionsFirst use case: a "scheduled report" form where the user types 0 6 * * 1-5. You need three things in that request — reject garbage with a 422, show "next run: Mon 06:00" under the field, and drive the actual schedule. That whole loop is the package in a nutshell:
$expr = CronExpression::factory($request->string('cron')); // throws on invalid
return response()->json([
'next_run' => $expr->getNextRunDate('now', 0, false, config('app.timezone'))
->format(DATE_ATOM),
]);
Wrap it in one application-level class. The most useful pattern, and the one that makes the eventual swap cheap, is a thin CronEvaluator service that never leaks Cron\CronExpression into controllers, models, or jobs:
final class CronEvaluator
{
public function isValid(string $expr): bool
{
return $expr !== '' && CronExpression::isValidExpression($expr);
}
public function nextRun(string $expr, ?DateTimeInterface $from = null, ?string $tz = null): CarbonImmutable
{
$next = CronExpression::factory($expr)
->getNextRunDate($from ?? 'now', 0, false, $tz ?? config('app.timezone'));
return CarbonImmutable::instance($next)->setTimezone($tz ?? config('app.timezone'));
}
public function isDue(string $expr, ?string $tz = null): bool
{
return CronExpression::factory($expr)->isDue('now', $tz ?? config('app.timezone'));
}
}
A validation rule, reused everywhere. Put the "is this parseable" check in a Rule object so form requests, Nova/Filament fields, and API resources all agree:
final class CronExpressionRule implements ValidationRule
{
public function __construct(private CronEvaluator $cron) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (!is_string($value) || !$this->cron->isValid($value)) {
$fail("The {$attribute} field must be a valid cron expression.");
}
}
}
Dynamic schedules from the database. The pattern that usually justifies the dependency: users define schedules in a table, and schedule() loops over them.
// routes/console.php or Console\Kernel::schedule()
foreach (ScheduledJob::where('active', true)->cursor() as $job) {
$schedule->call(fn () => $job->run())
->cron($job->cron_expression) // Laravel's own parser
->timezone($job->timezone)
->name("job:{$job->id}")
->withoutOverlapping();
}
Note the tension here: ->cron() already accepts the raw expression and Laravel parses it for you. Reach for your CronEvaluator only when you need something the scheduler doesn't give you — a preview, a catch-up window, or a manual due check.
Catch-up after downtime. The genuinely valuable use case: a job was down from Friday to Monday and you need to know which of its runs you missed.
$missed = [];
$cursor = $lastRunAt;
while (($next = $this->cron->nextRun($expr, $cursor))->lte(now())) {
$missed[] = $next;
$cursor = $next;
if (count($missed) > 100) break; // always bound the loop
}
Preview/UX endpoint. A debounced GET /schedules/preview?cron=... returning five upcoming runs as ISO strings is a small feature that removes most bad-cron support tickets, and it's ~15 lines with this parser.
Self-scheduling worker / isDue polling. If you poll more than once per minute (e.g. a while(true) worker, or several queue workers), gate it with a cache lock and a last-run marker — see gotchas below.
laravel/framework already depends on dragonmantank/cron-expression, which ships the same Cron\CronExpression FQCN as Cronos. Installing both puts two definitions of one class on the autoloader; whichever wins is not something you control. For most Laravel apps the honest conclusion is: you already have this capability — use the shipped parser, and only evaluate Cronos if you need a behaviour it has and the incumbent doesn't. If you do adopt it, add a Composer-level guard so the duplicate can't silently re-enter.mybuilder/cronos-bundle will pull Symfony DI into a Laravel app and give you nothing. Depend on mybuilder/cronos directly.composer.json PHP constraint against your runtime before committing; if it doesn't declare your PHP minor, expect deprecation notices or a composer conflict flag, and pin the version.?, L, W, #, year fields, and 6-field seconds are not guaranteed — write a test asserting the exact expression shapes you accept. Users pasting from Jenkins/Quartz/Kubernetes will hit this on day one, so validate on the input side with a friendly message rather than letting an InvalidArgumentException bubble out of a controller as a 500.@daily, @hourly, @weekly aliases are the exception that usually works — verify per version, and prefer expanding them to canonical 5-field form in your storage layer so a future parser swap stays a one-liner.factory() and the date methods throw on malformed input; isValidExpression() does not. Always validate first, then evaluate — never wrap the evaluation in a bare catch (\Throwable) and return null, or you'll silently lose schedules.config('app.timezone') (or the record's own timezone) rather than relying on date_default_timezone_set. getNextRunDate() honours the tz argument; skipping it produces previews that disagree with the scheduler, and during a DST spring-forward a 30 2 * * * job may legitimately skip a day — decide that behaviour deliberately, not by accident.isDue() is true for the whole minute. Polling twice in a minute fires twice, and overlapping workers double-fire. Use Cache::lock("cron:{$id}", 60) plus a last_ran_at comparison, or withoutOverlapping() if you're inside the scheduler.getNextRunDate loop (catch-up, "next five runs") must have a hard iteration cap. A pathological expression plus a far-past cursor will otherwise spin.factory() results per expression string in a request-scoped array; parsing is cheap individually and not free in bulk.Carbon::setTestNow(...) makes isDue/getNextRunDate deterministic; wrap the parser call in something injectable (the CronEvaluator above) so tests don't depend on the real clock.AppServiceProvider. Keep Cron\ behind that boundary — 87 stars and a mid-2023 release cadence is not a foundation to couple controllers to, and the swap to dragonmantank/cron-expression (near-identical API) should be a single-file change.How can I help you explore Laravel packages today?