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 Laravel Package

g4/cron

g4/cron provides cron scheduling utilities for Laravel and PHP applications, helping you define and manage recurring tasks with cron expressions. A lightweight way to organize scheduled jobs and keep timed workflows predictable.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and locate the entry point first. The package is a parser, not a Laravel package — there is no service provider, no config file, and no facade. Everything you need lives in a single class under vendor/g4/cron/src/.

composer require g4/cron

Then open the source before writing any code:

ls vendor/g4/cron/src
# Open the main CronExpression class and read the constructor + isDue() signature.

You are looking for three things, and you should confirm them against the source rather than assuming:

  1. The fully-qualified class name (typically G4\Cron\CronExpression).
  2. Field order for the seconds field — whether */10 * * * * * means "every 10 seconds" (seconds first) or something else. This is the single most important thing to verify; a fork that adds seconds usually prepends it, but confirm by calling getNextRunDate() on a known expression and comparing to wall-clock time.
  3. Which extended syntax is supported (?, L, W, #) — the upstream parser supports these, but forks vary.

First use case: a sub-minute health check. Laravel's scheduler bottoms out at one minute, so the reason you reach for this package is almost always "I need something to run every N seconds." Sanity-check it in php artisan tinker before building anything:

use G4\Cron\CronExpression; // verify namespace

$expr = new CronExpression('*/10 * * * * *'); // every 10 seconds (if seconds-first)

$expr->isDue();            // true when now() falls on a 10s boundary
$expr->getNextRunDate();   // DateTime you can diff against now()

If getNextRunDate() returns a time ~10 seconds in the future, you have the field order right. If it returns something minutes or hours away, your expression has one field too many and the seconds field is in the wrong place.

Implementation Patterns

Wrap it, don't sprinkle it. Because there is no service provider, register a thin wrapper yourself so the rest of the codebase never touches the vendor class directly:

// app/Support/SecondPrecisionCron.php
namespace App\Support;

use G4\Cron\CronExpression;
use Carbon\CarbonInterface;

class SecondPrecisionCron
{
    public function isDue(string $expression, ?CarbonInterface $at = null): bool
    {
        return (new CronExpression($expression))
            ->isDue($at?->toDateTimeString() ?? 'now');
    }

    public function nextRun(string $expression): \DateTime
    {
        return (new CronExpression($expression))->getNextRunDate();
    }
}

This gives you one place to swap the library out if it goes unmaintained (see Gotchas — this is a 6-star, single-release package).

Pattern 1 — a long-running ticker command. The canonical use for a seconds-resolution parser is a daemon that wakes up once per second, evaluates a set of stored expressions, and dispatches jobs. Keep the sleep interval below your smallest granularity, and re-read the clock every tick:

public function handle(SecondPrecisionCron $cron)
{
    while (true) {
        $now = now();
        $started = microtime(true);

        foreach (Schedule::where('enabled', true)->get() as $schedule) {
            if ($cron->isDue($schedule->expression, $now)) {
                dispatch(new RunScheduledTask($schedule));
                $schedule->update(['last_dispatched_at' => $now]);
            }
        }

        // Sleep to the next whole second, minus elapsed work.
        $elapsed = microtime(true) - $started;
        usleep((int) max(0, (1.0 - $elapsed) * 1_000_000));
    }
}

Supervise this with Supervisor (or Horizon for the dispatched jobs) — do not run it from Laravel's scheduler, or you will get overlapping daemons.

Pattern 2 — validate user input. Anywhere a user can type a cron string (admin UI, tenant config), validate with the library rather than a regex:

use G4\Cron\CronExpression;

$valid = CronExpression::isValidExpression($request->input('expression'));
// Static validity check + try/catch on construction for clearer errors.

Catch the constructor's exception and surface the message — the upstream parser has decent human-readable errors for bad field counts and out-of-range values.

Pattern 3 — preview "next 5 runs" in the UI. This is where the package earns its keep and where the seconds field is visibly useful:

$expr = new CronExpression($userExpression);
$next = $expr->getNextRunDate();
$runs = [];
for ($i = 0; $i < 5; $i++) {
    $runs[] = $next;
    $next = $expr->getNextRunDate($next->format('Y-m-d H:i:s'));
}

Always pass the previous result back in. If you call getNextRunDate() in a loop without an anchor, you get the same value five times.

Pattern 4 — keep Laravel's own scheduler on the standard parser. Do not try to make Illuminate\Console\Scheduling\* seconds-aware; it is built on dragonmantank/cron-expression with 5-field semantics and will silently misparse or throw. Use the two side by side:

  • Minute-or-coarser schedules → Kernel::schedule() as normal.
  • Sub-minute schedules → the ticker command above, driven by g4/cron.

Pattern 5 — test with an injected clock. Never test against real time. Every method takes an anchor date, so assert exact due/not-due boundaries:

$this->assertTrue($cron->isDue('*/10 * * * * *', Carbon::parse('2024-01-01 00:00:10')));
$this->assertFalse($cron->isDue('*/10 * * * * *', Carbon::parse('2024-01-01 00:00:11')));

Write one test per syntax feature you rely on (*/n, ranges, L, #). You are pinning vendor behaviour, and vendor behaviour here is not covered by a large upstream userbase.

Gotchas and Tips

The field-order trap is real and silent. A 5-field expression (*/5 * * * *) handed to a seconds-aware parser, or vice versa, does not necessarily throw — it can shift everything by one field and produce a schedule that is wrong by a factor of 60. Defend by counting fields at the boundary and rejecting anything that does not match your expected count:

if (count(preg_split('/\s+/', trim($expression))) !== 6) {
    throw new InvalidArgumentException('Expected 6 fields (seconds-first).');
}

Namespace collision with dragonmantank/cron-expression. Laravel ships dragonmantank/cron-expression as a direct dependency. g4/cron is a fork of it, so you now have two packages implementing near-identical classes. Do not mix them up in use statements, do not try to alias one over the other, and do not assume a fix or feature in one applies to the other. In code review, treat use G4\Cron\... and use Cron\... as different worlds.

This is a low-traffic dependency — pin it and abstract it. Six stars, zero dependents, one release (Nov 2023). Treat it as unmaintained until proven otherwise: commit composer.lock, require an exact or ~ constraint, and route all calls through your wrapper class so a future replacement is a one-file change. If the seconds-parsing logic is the only thing you need, consider copying the CronExpression class into app/Support under MIT terms and deleting the dependency — for a class this size, that is a legitimate option and removes supply-chain risk.

Drift and DST in the ticker loop. sleep(1) plus a non-trivial batch of due checks means ticks creep past the second boundary and you skip occurrences. Sleep to the next boundary (as in the snippet above) rather than a flat 1s. Also pass an explicit timezone — the parser resolves relative to the timezone of the DateTime you give it, and a container defaulting to UTC while your DB stores local time will produce off-by-hours behaviour that only shows up at DST transitions.

Cache parsed expressions. Constructing CronExpression per row per tick parses the string every time. In the ticker, memoise by expression string in a local array; parse cost is trivial once, and this matters when you have a few hundred tenant schedules.

isDue() is a point-in-time check, not a scheduler. It answers "does this expression match this instant," not "should I run now given the last run." If a tick is missed (deploy, restart, GC pause), isDue() will not tell you to catch up, and a naive ticker will silently skip that occurrence. If you need catch-up semantics, compare getPreviousRunDate() against a stored last_dispatched_at and backfill.

Sub-minute work belongs in jobs, not the ticker. Never do the actual work inline in the loop — a slow job will starve every other schedule on the same process. Dispatch and return immediately; let the queue absorb duration and retries.

Extension points are thin but sufficient. There is no field-factory config to tune by default, but the upstream class exposes setMaxIterationCount() for pathological expressions (e.g. a # or L expression with no near-term match) and accepts a custom field factory via the constructor if you genuinely need non-standard syntax. For almost all cases, subclassing or wrapping your own isDue() is cleaner than trying to extend the parser internals.

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