rennokki/schedule
Manage Laravel scheduled tasks from the database instead of the console kernel. Define frequencies with a fluent API, track run history, and control jobs through a built-in manager, no code deploys needed to adjust schedules.
Install and publish in three commands
composer require rennokki/schedule
php artisan vendor:publish --tag=schedule:migrations # check the tag in the README/ServiceProvider
php artisan migrate
There is no meaningful config surface — the deliverable is a schedule table (polymorphic schedulable_type / schedulable_id, plus start / end) and a trait. Read the published migration first: it tells you the exact column names, nullability, and whether timestamps/indexes are included, which determines every query you'll write afterwards.
Attach the trait to every model that can hold a schedule
use RenokiCo\Schedule\Traits\HasSchedule;
class Worker extends Model
{
use HasSchedule;
}
The trait is intentionally model-agnostic — User, Room, Vehicle, Machine all work. Pick the trait up once, verify $worker->schedules returns a collection, and you know the wiring is correct before building anything on top.
First use case: "is this person free?"
The canonical day-one flow is a booking/availability check. Don't start by writing a booking system — start by proving availability queries behave:
$start = now()->addDay()->setTime(9, 0);
$end = $start->copy()->addHours(4);
if ($worker->isAvailable($start, $end)) {
// safe to offer this slot
}
Where to look first, in order: published migration → the HasSchedule trait source → RenokiCo\Schedule\Models\Schedule (scopes and casts live there) → README examples. Because the package is archived, treat the source as the spec; don't assume the README reflects the last tagged release.
Also check composer.json constraints before installing on anything modern. A 2019 package will pull Carbon 1.x and predates Laravel 6+ conventions, so on Laravel 9/10/11 you will likely need --ignore-platform-reqs or a fork. Verify this in a throwaway branch first.
Availability checks as a gate, not a guarantee. The useful pattern is a two-phase flow: query availability for the UI, then re-check inside a transaction when writing. The check is not atomic by itself, so the second check is what actually protects you.
DB::transaction(function () use ($worker, $start, $end) {
$worker = Worker::whereKey($worker->id)->lockForUpdate()->first();
abort_if($worker->isUnavailable($start, $end), 409, 'Slot taken');
$worker->schedules()->create([
'start' => $start,
'end' => $end,
]);
});
Store UTC, convert at the edges. Pass Carbon instances (or Y-m-d H:i:s strings in app timezone consistently) — never mix toDateString() with datetimes. A single timezone convention in the service layer, converted in the FormRequest and API Resource, eliminates almost every bug you'd otherwise chase in this package.
Roster / "who is free right now" queries. Use the relation for set-based queries rather than looping and calling isAvailable() per model — the latter is an N+1 by construction.
$free = Worker::whereDoesntHave('schedules', function ($q) use ($start, $end) {
$q->where('start', '<', $end)
->where('end', '>', $start);
})->get();
Keep this overlap predicate in one place (a scope on your own model or the Schedule model) so the whole app agrees on whether back-to-back shifts conflict.
Split shifts are just multiple rows. A 09:00–12:00 and 13:00–17:00 day is two schedule records. Don't model a "day" entity on top; let the collection be the source of truth and let the UI group by day.
Recurrence is your job, not the package's. There is no RRULE engine here. Materialize recurring shifts with a scheduled command that writes concrete rows for the next N weeks, and make it idempotent (unique index on schedulable_type, schedulable_id, start plus insertOrIgnore/firstOrCreate). This keeps availability queries simple and makes edits to a single occurrence trivial.
Booking flow wiring. Request validation (end > start, max duration) → availability check → create → fire your own domain event → queue a confirmation. Keep the package at the persistence layer; business rules (max hours, blackout dates, approval states) belong in your service or a SchedulePolicy, not in the trait.
Eager load for calendars. A month view over 50 workers is Worker::with(['schedules' => fn ($q) => $q->whereBetween('start', [$from, $to])]). Always constrain the eager load — an unfiltered with('schedules') on a busy workforce model will return years of rows.
Cache availability carefully. Short TTL (30–60s) on "free slots for worker X on date Y" is fine; anything longer and you're showing stale slots. Invalidate on schedule create/update/delete via a model observer on Schedule.
Expose it through a resource, not the model. Return [{start, end}] windows plus a has_availability boolean — clients shouldn't be reconstructing overlap math from raw rows.
The archive is the biggest gotcha. No security patches, no Laravel version bumps, no bug fixes. Pin an exact version ("rennokki/schedule": "1.0.x"), commit the lock file, and write an ADR noting the exit plan. If the app is long-lived, budget a migration to ~100 lines of your own migration + trait + one overlap scope; that is genuinely all this package gives you, and owning it removes the dependency risk entirely. spatie/period is a reasonable helper if you later need real interval arithmetic (merging, subtracting, gaps).
Overlap semantics: decide, then be consistent. The standard test is half-open intervals — existing.start < new.end AND existing.end > new.start — which treats touching shifts (12:00 end, 12:00 start) as non-conflicting. whereBetween is not an overlap test and will silently miss schedules that start before and end after your window. Never use it for availability.
Carbon version conflict. Expect a fight between this package's Carbon 1.x requirement and anything modern. Check composer why-not before blaming the code. If you fork, the only change usually needed is loosening the illuminate/* and nesbot/carbon constraints.
"No schedule" vs "not available". An empty schedule set can mean either "never works" or "no restrictions set yet". Decide explicitly and encode it (e.g., a tracks_schedule flag on the worker). This ambiguity produces the most confusing production bugs in scheduling apps.
No DB-level exclusion constraint in MySQL. Postgres users can add a tsrange exclusion constraint and be done. On MySQL, use a unique index on (schedulable_type, schedulable_id, start) to block double-submits, and accept that true overlap prevention needs the lockForUpdate pattern above.
Double-submit is the #1 field bug. Two browser tabs, both pass the check, both insert. Add the unique index and a disabled-while-submitting button; the DB constraint is the only reliable fix.
DST. A two-hour shift spanning a DST boundary is one or three hours of wall-clock time. Store UTC datetimes and compute durations with diffInMinutes() on UTC values; render local times only in the view layer.
Test with frozen time. Carbon::setTestNow() plus model factories for both the worker and the schedule. Write explicit tests for: exactly-adjacent shifts, fully-enclosing windows, zero-length schedules (reject them in validation), and a schedule starting before the query window. These four cases catch essentially every overlap bug.
Debugging queries. When availability looks wrong, dump $query->toSql() and getBindings() — 90% of the time it's a timezone-converted string or a whereBetween that slipped in. DB::listen() during a manual test run is faster than adding dd()s.
Extension points. Extend Schedule with your own subclass and point the relation at it if you need extra columns (type, notes, approved_by) — but you must own the migration too, since the package's migration won't include them. Register observers on the Schedule model for cache invalidation and audit logging rather than overriding trait methods.
Indexes matter here. Add a composite index on (schedulable_type, schedulable_id, start, end). Without it, whereDoesntHave over a workforce table degrades badly as rows accumulate.
How can I help you explore Laravel packages today?