Repository navigation
Declaring Recurring Models
This page shows how to make an Eloquent model recurring with the HasRecurrence trait. Read it before you attach rules to records: it covers column names, models without an end, mass assignment, soft deletes and custom builders.
Add HasRecurrence to the model. The trait registers a global scope that expands every series into its occurrences, and a cast for the recurrence_rule attribute. Your table needs no new columns: the rule and its occurrences live in the package's own tables.
The examples in this wiki use this Meeting model:
<?php
namespace App\Models;
use BoysFromTheFactory\Groundhog\Concerns\HasRecurrence;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Meeting extends Model
{
use HasRecurrence;
use SoftDeletes;
protected $guarded = [];
protected function casts(): array
{
return [
'starts_at' => 'datetime',
'ends_at' => 'datetime',
];
}
public function room(): BelongsTo
{
return $this->belongsTo(Room::class);
}
public function attendees(): HasMany
{
return $this->hasMany(Attendee::class);
}
}The meetings table has title, location, capacity, room_id, starts_at, ends_at, timestamps and deleted_at. Create a series by giving a record a rule, then query it like any other model:
Meeting::create([
'title' => 'Standup',
'location' => 'Room A',
'starts_at' => '2026-03-02 09:00:00',
'ends_at' => '2026-03-02 10:00:00',
'recurrence_rule' => 'FREQ=WEEKLY;BYDAY=MO',
]);
Meeting::whereBetween('starts_at', ['2026-03-01 00:00:00', '2026-03-31 23:59:59'])
->orderBy('starts_at')
->get();
// => 5 meetings titled "Standup" in "Room A", starting 2, 9, 16, 23 and 30 March at 09:00
// and ending at 10:00 on the same daysBy default the trait reads each occurrence's start from starts_at and its end from ends_at. If your columns have other names, declare them with the RECURRENCE_STARTS_AT and RECURRENCE_ENDS_AT constants:
class Booking extends Model
{
use HasRecurrence;
public const RECURRENCE_STARTS_AT = 'begins_at';
public const RECURRENCE_ENDS_AT = 'finishes_at';
}The series record's own start is the first occurrence, and the difference between its start and end is the duration of every occurrence. Cast both columns to datetime so you get Carbon instances back.
When a model has no end column, set RECURRENCE_ENDS_AT to null. Occurrences then carry only a start.
class Shift extends Model
{
use HasRecurrence;
public const RECURRENCE_ENDS_AT = null;
protected $fillable = ['label', 'starts_at', 'recurrence_rule'];
protected function casts(): array
{
return [
'starts_at' => 'datetime',
];
}
}Shift::create(['label' => 'Early', 'starts_at' => '2026-03-02 06:00:00', 'recurrence_rule' => 'FREQ=DAILY;COUNT=2']);
$shifts = Shift::orderBy('starts_at')->get();
// => 2 shifts starting 2026-03-02 06:00 and 2026-03-03 06:00
array_key_exists('ends_at', $shifts->first()->getAttributes());
// => falserecurrence_rule is an attribute like any other, so mass assignment rules apply to it. A model that uses $fillable must list recurrence_rule; a model with $guarded = [], such as Meeting, accepts it as is.
// Shift lists 'recurrence_rule' in $fillable
Shift::create(['label' => 'Early', 'starts_at' => '2026-03-02 06:00:00', 'recurrence_rule' => 'FREQ=DAILY;COUNT=3']);
Shift::orderBy('starts_at')->get();
// => shifts starting 2026-03-02 06:00, 2026-03-03 06:00 and 2026-03-04 06:00If you leave recurrence_rule out of $fillable, Laravel discards it during create() and the record is saved as a plain, non-recurring record. You can still assign it directly: $shift->recurrence_rule = 'FREQ=DAILY'; $shift->save();.
Use Laravel's SoftDeletes trait next to HasRecurrence, as Meeting does. Nothing else is needed. Soft-deleting a series hides it and its occurrences until you restore it, and soft-deleting an exception keeps its occurrence cancelled while it is trashed. See Soft deleting and restoring a series and Deleting an exception (hard vs soft).
Recurring models use BoysFromTheFactory\Groundhog\Query\RecurringBuilder, which keeps key lookups and query-level writes on stored rows. If you declare your own builder, it must extend RecurringBuilder:
use BoysFromTheFactory\Groundhog\Query\RecurringBuilder;
use Illuminate\Database\Eloquent\Attributes\UseEloquentBuilder;
class MeetingBuilder extends RecurringBuilder
{
public function inRoomA(): static
{
return $this->where('location', 'Room A');
}
}
#[UseEloquentBuilder(MeetingBuilder::class)]
class Meeting extends Model
{
use HasRecurrence;
}Meeting::query();
// => an instance of MeetingBuilder
Meeting::whereBetween('starts_at', ['2026-03-01 00:00:00', '2026-03-31 23:59:59'])->count();
// => 5 for the weekly Monday "Standup" seriesA builder that extends Laravel's plain Illuminate\Database\Eloquent\Builder instead throws IncompatibleEloquentBuilder as soon as a query is started, with a message like "The Eloquent builder App\Models\MeetingBuilder of recurring model App\Models\Meeting must extend BoysFromTheFactory\Groundhog\Query\RecurringBuilder." See IncompatibleEloquentBuilder.
Next: Recurrence Rules
Documents Groundhog v0.2.0 · Repository · README · Changelog