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

Filament Recurrence Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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

  1. vendor/andreia/filament-recurrence/README.md — the authoritative list of component methods; do not assume method names beyond make().
  2. The component class itself — inspect the default ->dehydrateStateUsing()/->formatStateUsing() implementations to confirm what is stored (RRULE string vs JSON payload). This single detail determines your entire persistence and query strategy.
  3. config/filament-recurrence.php after publishing — locale, default frequency, and any allow-list of frequency options usually live here.

Implementation Patterns

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:

  • Keep the rule on the parent.
  • Have a queued job (ExpandedRecurrenceJob) that deletes unstarted, unmodified occurrences and re-expands a bounded window (e.g. 12 months ahead).
  • Mark any occurrence a user edited with 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.
  • Re-dispatch the job from the model's 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

  • Pair the component with a plain DateTimePicker for the first occurrence. The rule should describe repetition, not the anchor datetime; conflating them is the most common modelling mistake here.
  • For multi-tenant or approval workflows, use ->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.
  • If the panel uses a non-default locale, set it explicitly on the component and verify humanReadable() output matches; RRULE libraries rarely translate BYDAY abbreviations consistently.

Gotchas and Tips

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.

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.
elsayed85/laravel-api-exceptions
ouhssini/laravel-demo-mode
daworks/ncloud-outbound-mailer-driver
gp247/cms
ignibyte/aic-gates
nexa-merchant/blog
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
cgv/localized-resource