Search by

Declarative recurring Eloquent models backed by RFC 5545 rules.

Package info

github.com/boysfromthefactory/groundhog

pkg:composer/boysfromthefactory/groundhog

Statistics

Installs: 7

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.2.0 2026-10-05 02:12 UTC

This package is auto-updated.

Last update: 2026-11-01 22:38:53 UTC


README

Declarative recurring Eloquent models backed by RFC 5545 rules.

Add one trait to a model, give a record a recurrence rule, and keep querying it like any other model: every read returns one hydrated, non-stored instance per occurrence, next to your plain records. Pagination, counts, aggregates, ordering, whereHas, joins and eager loading all work over the occurrences. Saving an occurrence stores it as an exception of its series; deleting one cancels it.

Rules are parsed and expanded by rlanvin/php-rrule.

Documentation

The full guide, with examples for every feature, is in the wiki: Quick Start · Usage guide · Configuration · Errors · Limitations

Requirements

  • PHP 8.4+, Laravel 13
  • SQLite 3.35+, MySQL 8.4 or PostgreSQL 14+ (SQL Server is not supported)
  • ext-intl is recommended for localised humanReadable() output

Installation

composer require boysfromthefactory/groundhog
php artisan vendor:publish --tag="groundhog-migrations"
php artisan migrate

Optionally publish the configuration:

php artisan vendor:publish --tag="groundhog-config"
return [
    // ISO-8601 duration: how far an infinite rule is expanded after the query's lower start
    // bound (or after now) when the query gives no upper bound.
    'horizon' => 'P1Y',

    // Most occurrences one series may generate for one query (or on save); more fails with
    // OccurrenceLimitExceeded.
    'max_occurrences_per_series' => 50000,
];

The migration creates two tables: groundhog_recurrences (one rule per series) and groundhog_exclusions (exceptions and cancellations). Occurrences are generated per query and never stored. Your own tables need no new columns.

Upgrading from 0.1: run php artisan vendor:publish --tag="groundhog-migrations" and php artisan migrate again. The newly published migration drops the groundhog_occurrences table and the materialized_until column.

Declaring a recurring model

use BoysFromTheFactory\Groundhog\Concerns\HasRecurrence;
use Illuminate\Database\Eloquent\Model;

class Meeting extends Model
{
    use HasRecurrence;

    // Only needed when your columns are named differently (defaults shown).
    public const RECURRENCE_STARTS_AT = 'starts_at';
    public const RECURRENCE_ENDS_AT = 'ends_at'; // null when the model has no end column

    protected $fillable = ['title', 'starts_at', 'ends_at', 'recurrence_rule'];
}

List recurrence_rule in $fillable if the model uses $fillable; $guarded models accept it as is.

Assigning rules

A rule is attached through the recurrence_rule attribute and saved with the record:

Meeting::create([
    'title' => 'Standup',
    'starts_at' => '2026-03-02 09:00',
    'ends_at' => '2026-03-02 10:00',
    'recurrence_rule' => 'FREQ=WEEKLY;BYDAY=MO',
]);

The rule may be an RRULE string, a string with a DTSTART;TZID=… line, a php-rrule parts array (['FREQ' => 'WEEKLY', 'BYDAY' => 'MO']) or an RRule\RRule. All RFC 5545 RRULE components are supported. The first occurrence and the duration of every occurrence always come from the record's own start and end; a DTSTART in the input only contributes its time zone. Occurrences keep their local wall-clock time across daylight-saving changes in that zone.

Invalid input throws InvalidRecurrenceRule naming the offending part as soon as it is assigned. EXDATE, RDATE and EXRULE are rejected: exclusions come from cancelling or editing occurrences. Assign null to turn a series back into a plain record.

The rule cast

$meeting->recurrence_rule returns an RRule\RRule, so every php-rrule method works:

$meeting->recurrence_rule->humanReadable(); // "weekly on Monday, starting from 3/2/26, forever"
$meeting->recurrence_rule->getOccurrencesBetween('2026-03-01', '2026-03-31');

It reflects the rule only; cancellations and exceptions are not applied to it.

Querying

Write queries as for any model:

Meeting::whereBetween('starts_at', [$from, $to])
    ->where('location', 'Room A')
    ->orderBy('starts_at')
    ->paginate(25);

Each occurrence carries the series' attributes with its own start and end, reports exists === false, has no primary key, and exposes:

$occurrence->isVirtualOccurrence();     // true
$occurrence->series;                    // the stored series record
$occurrence->originalOccurrenceStart(); // the start the rule generated

An occurrence is addressed by groundhog_series_key plus groundhog_original_starts_at; both are present on every row (null for plain records) and serialised by toArray().

A rule without COUNT or UNTIL never ends, so a query without an upper bound on the start or end column expands it up to the configured horizon after its lower bound, or after now.

Editing, cancelling and deleting

$occurrence->update(['location' => 'Room B']); // stored as an exception of this occurrence only
$occurrence->delete();                          // cancels this occurrence; nothing is stored

save(), update(), increment() and decrement() on an occurrence persist it as an exception, even without changes, and fire the usual creating/created/saved events. Hard-deleting an exception keeps its occurrence cancelled; soft-deleting it hides it until restore().

Editing the series record changes every occurrence that has no exception. Changing its rule or its start detaches its exceptions as plain records and discards its cancellations; changing only its end, or assigning the identical rule, keeps both. Deleting a series removes its rule, occurrences and exceptions; soft-deleting it hides them until restore().

Caveats

  • Lookups by primary key (find, whereKey, destroy, route model binding, belongs-to relations pointing at the model) and query-level writes (update, delete, increment, insert, upsert, …) act on stored rows only and never create exceptions or cancellations. withoutOccurrences() gives any query that view.
  • Relations keyed on the model's own primary key (e.g. hasMany attendees) are empty on occurrences, which have no key; reach them through $occurrence->series.
  • A custom Eloquent builder must extend BoysFromTheFactory\Groundhog\Query\RecurringBuilder.
  • chunkById(), lazyById() and eachById() throw while occurrences are expanded; use chunk()/lazy(), or withoutOccurrences() for stored rows.
  • Stored rows streamed by withoutOccurrences()->cursor() get the identity attributes only once isOccurrenceException(), originalOccurrenceStart() or series is used; use get() or lazy() when serialising them.
  • A series whose rule generates more than max_occurrences_per_series occurrences between its start and the horizon is rejected on save, and a query whose window holds more than that many occurrences of one series throws OccurrenceLimitExceeded.
  • Nothing derived from a rule is stored: every expanded query loads all series of the model type and generates their occurrences for its window, so cost grows with series × occurrences in the window. 1,000 daily series over a one-year window take about 5–8 s for a page of 25 with its total (SQLite, MySQL 8.4, PostgreSQL 18; see the wiki's How It Works page).
  • Expanded queries on any driver other than SQLite, MySQL or PostgreSQL throw RecurrenceNotSupported.
  • cursorPaginate() is not supported, and occurrences cannot be queued by identity.
  • Bulk deletes of series rows bypass the cleanup of their rules, as Eloquent bulk deletes bypass model events.

Testing

composer test     # SQLite in memory
DB_CONNECTION=mysql DB_DATABASE=groundhog composer test
DB_CONNECTION=pgsql DB_DATABASE=groundhog composer test
composer analyse  # Larastan, level 9
composer bench    # SC-003 pagination timing on DB_CONNECTION (recreates its schema)

Changelog

See CHANGELOG.

License

The MIT License (MIT). See License File.