andreia/filament-recurrence
Filament Recurrence adds a user-friendly recurring schedule field to Filament forms. Configure frequencies, intervals, weekdays, and end dates, then save recurrence rules with your models for easy reuse and display. Ideal for events, appointments, and reminders.
Install and pin
composer require andreia/filament-recurrence
Given the small install base, pin a known-good version in composer.json ("andreia/filament-recurrence": "^1.0" or an exact tag) rather than floating, and commit the lock file. Check the README's compatibility matrix for your Filament major (v3 vs v4) and PHP version before wiring anything else — Filament plugin APIs shifted between majors, and a mismatch usually surfaces as a broken asset build rather than a clean exception.
Publish assets/config, if offered
php artisan vendor:publish --tag="filament-recurrence-config"
php artisan vendor:publish --tag="filament-recurrence-views"
Many Filament UI plugins require php artisan filament:assets (or a panel ->viteTheme() rebuild) before the widget renders correctly. If the component appears unstyled, that is nearly always the cause.
Add a storage column
The component produces a recurrence rule, not occurrences. Persist it as a nullable text column — RRULE strings are short but open-ended, so text is safer than string(255):
Schema::table('events', function (Blueprint $table) {
$table->text('recurrence')->nullable()->after('ends_at');
});
Add 'recurrence' => 'nullable|string' to $fillable/$casts as a plain string. Treat the value as opaque: your application code, not the form component, should be the only thing that parses it.
First use case — the one to build first
A recurring weekly meeting: pick a start DateTimePicker, add the recurrence component, and render a human summary in the table.
use Andreia\FilamentRecurrence\Forms\Components\Recurrence;
DateTimePicker::make('starts_at')->required()->seconds(false),
Recurrence::make('recurrence')
->label('Repeats')
->required(),
Then a read-only display:
TextColumn::make('recurrence')
->label('Repeats')
->formatStateUsing(fn (?string $state) => $state
? \RRule\RRule::createFromRfcString($state)->humanReadable()
: 'Does not repeat'),
That single column is the highest-value thing you can ship in an afternoon: users get the flexible editor, and the table stays readable without you expanding any occurrences.
Where to look first when something is off
vendor/andreia/filament-recurrence/README.md — the authoritative list of component methods; do not assume method names beyond make().->dehydrateStateUsing()/->formatStateUsing() implementations to confirm what is stored (RRULE string vs JSON payload). This single detail determines your entire persistence and query strategy.config/filament-recurrence.php after publishing — locale, default frequency, and any allow-list of frequency options usually live here.Pattern 1: Store RRULE, expand on demand
The cleanest integration is to keep the recurrence rule on the parent record and never persist occurrences. Expand at read time with an RRULE library (rlanvin/php-rrule for RFC 5545 output; simshaun/recurr if you need to generate rules too):
use RRule\RRule;
use Carbon\CarbonImmutable;
public function occurrencesBetween(CarbonImmutable $from, CarbonImmutable $until): array
{
if (blank($this->recurrence)) {
return [$this->starts_at];
}
return (new RRule($this->recurrence))
->getOccurrencesBetween($from, $until);
}
This gives you drag-free editing (change the rule, the future instantly looks different) and no cleanup of stale rows. Use it for calendars, scheduling previews, and "next 5 dates" widgets.
Pattern 2: Materialized occurrences table
Build this only when occurrences acquire their own state — attendance, per-session notes, cancellations of a single instance, invoices. Then:
ExpandedRecurrenceJob) that deletes unstarted, unmodified occurrences and re-expands a bounded window (e.g. 12 months ahead).is_exception = true so re-expansion never destroys manual changes. This is how you approximate RFC 5545 EXDATE/RECURRENCE-ID without implementing full iCalendar semantics.updated hook when recurrence or starts_at changes.Pattern 3: A live preview beside the form
The "beautiful UI" is the selling point — reinforce it by showing the next few dates as the user edits. Use Filament's reactive fields:
Recurrence::make('recurrence')
->live()
->afterStateUpdated(fn ($state, Set $set, Get $get) =>
$set('preview', $this->previewDates($state, $get('starts_at')))
),
Placeholder::make('preview')
->content(fn (Get $get) => collect($get('preview') ?? [])->join(', ')),
Cap the preview at 5–10 dates and wrap it in a try/catch — half-typed rules and timezone-less strings will throw, and an exception inside afterStateUpdated in Filament surfaces as a toast at best and a 500 at worst.
Pattern 4: Summary column + filter
Store a denormalized recurrence_summary string on save ($model->recurrence_summary = $rrule->humanReadable();). Then table search, CSV exports, and notification emails never need to re-parse. Add a simple TernaryFilter on recurrence IS NULL ("Repeating / One-off") which is what support teams actually ask for.
Pattern 5: DTO for the UI payload
If the component stores more than a bare RRULE (frequency + interval + byday + ends-on as separate keys), immediately normalize it into a value object on saving:
protected function rules(): array
{
return ['recurrence' => ['nullable', 'string', new ValidRecurrenceRule]];
}
protected static function booted(): void
{
static::saving(function (self $model) {
$model->recurrence = app(RecurrenceNormalizer::class)->toRrule($model->recurrence);
});
}
Converting once at the boundary means the rest of your codebase only ever deals with RFC 5545 strings, and swapping the UI plugin later becomes a one-file change. Given the package's maturity, that optionality is worth the small upfront cost.
Integration tips
DateTimePicker for the first occurrence. The rule should describe repetition, not the anchor datetime; conflating them is the most common modelling mistake here.->disabled(fn ($record) => $record?->is_locked) rather than conditionally removing the field — Filament's hidden() + dehydrated() interaction will silently null out the rule on save.humanReadable() output matches; RRULE libraries rarely translate BYDAY abbreviations consistently.UNTIL vs COUNT, and the off-by-one
RFC 5545 says UNTIL is inclusive and that COUNT and UNTIL are mutually exclusive. If the UI lets a user pick "ends after 10 occurrences", verify whether the package emits COUNT=10 or computes a UNTIL from the start date — the latter drifts for monthly rules anchored on the 31st. Round-trip a rule out of the form and back in before trusting it.
Timezone is where recurrences actually break
An RRULE without a DTSTART is ambiguous. If the component doesn't store the anchor, derive it from starts_at, convert to UTC for storage, and convert back to the display timezone on expansion. Weekly rules crossing a DST boundary will otherwise shift by an hour, or worse, skip/duplicate a day. Test explicitly with America/New_York around a March/November transition — a rule at 09:00 local must stay at 09:00 local, not drift to 08:00.
BYDAY means different things monthly
FREQ=MONTHLY;BYDAY=MO is every Monday of every month; FREQ=MONTHLY;BYDAY=1MO is the first Monday. If the UI exposes "monthly on weekday", confirm which form it emits. This is the single most common bug report on recurrence features.
Never expand an unbounded rule in a request
FREQ=DAILY with no end will iterate forever. Always use getOccurrencesBetween() with both bounds and a hard cap:
$occurrences = (new RRule($rule))
->getOccurrencesBetween($from, $until);
throw_if(count($occurrences) > 500, new TooManyOccurrencesException());
Empty string vs null
Filament sometimes dehydrates an "empty" structured field to [] or '' rather than null. Normalize in a mutator ($value === '' ? null : $value), otherwise whereNull('recurrence') filters miss every one-off record and your blank() checks pass through malformed input.
Validation is on you
Expect the component to validate its own internal consistency but not the combination with your start date. Add a custom rule: end date must be after start, and if a rule is present the start date becomes required. Return friendly messages — "Repeat cannot end before it starts" beats a raw RRULE parse exception.
Debugging: log the generated string
When the UI and the persisted value disagree, Log::debug($state) inside dehydrateStateUsing is the fastest path. Also diff $model->getOriginal('recurrence') against $model->recurrence in a saving listener — Filament's afterStateHydrated can silently rewrite a rule the user never touched, which shows up as "the schedule changed itself overnight."
Extension points before forking
Before writing patches, exhaust the standard Filament levers: ->label(), ->helperText(), ->rule(), ->formatStateUsing(), ->dehydrateStateUsing(), a custom ViewField wrapper, and Blade view overrides after publishing. Most "the plugin doesn't support X" complaints resolve at the dehydrate/hydrate boundary rather than inside the component.
Maintenance risk
21 stars and a single-maintainer release cadence means you are, in practice, the maintainer of your integration. Concrete mitigations: wrap the component in your own App\Filament\Components\RecurrenceField factory so there is exactly one use statement to change; normalize to RRULE strings at the boundary so the library is replaceable; write a Pest test that asserts a fixed set of UI payloads map to expected RRULE strings, so a breaking minor release fails CI instead of production; and add the repo to your dependency-review watchlist. The MIT license makes a fork viable if the package stalls, which is precisely why keeping the surface area to one wrapper class matters.
When to skip this package
If you only need "every N days/weeks/months" with a simple dropdown, you don't need a full recurrence engine — but the moment you need "second Tuesday and last Friday of the month", hand-rolled selectors collapse. The component earns its place when the rule genuinely is complex and the user is non-technical; for developer-facing config, a plain RRULE text input plus validation is less code and less risk.
How can I help you explore Laravel packages today?