How to Build a Recurring Events System? Database Schema vs Cron vs RRULE

How to Build a Recurring Events System? Database Schema vs Cron vs RRULE

7 min read

Scheduling recurring events (Recurring Events) is one of the most common architectural challenges backend developers face when building booking or calendar systems. Whether you are building a system to manage trainer availability or a medical clinic, how you represent "time" in the database fundamentally impacts system performance and scalability.

In this article, we will explore three different approaches to tackling this challenge, analyzing the strengths and weaknesses of each technology to help you make the right architectural decision for your project.


Approach 1: Custom Database Schema

The initial intuitive approach is to translate scheduling rules into tables and relationships within a relational database (Relational Model). This typically involves creating a table for availability and another table for exceptions.

Here, we will use a trainer availability model as a practical example of custom recurrence representation inside a database.

PHP
// Migration for the initial availability schema
Schema::create('trainer_availability', function (Blueprint $table) {
    $table->id();
    $table->foreignId('trainer_id')->constrained('trainers')->onDelete('cascade');

    // Storing the day as an integer (e.g., 0 for Sunday, 1 for Monday)
    $table->integer('day_of_week')->unsigned()->default(0);
    $table->timeTz('start_time');
    $table->timeTz('end_time');

    $table->timestamps();
});

// Migration for exceptions (e.g., holidays, sick leaves)
Schema::create('trainer_availability_exceptions', function (Blueprint $table) {
    $table->id();
    $table->foreignId('trainer_id')->constrained('trainers')->onDelete('cascade');
    $table->enum('type', ['available', 'unavailable']);
    $table->date('date');
    $table->timestamps();
});

Strengths

  • Simplicity of initial queries: You can easily use standard SQL queries to search for appointments on a specific day.
  • No dependency on a dedicated recurrence library: Core recurrence logic is built directly into the system (even if you rely on date/time utilities).

Weaknesses

  • Rigidity: If the system later requires complex rules (such as "every 2 weeks" or "on the first Friday of the month"), it demands radical schema migrations.
  • Escalating query complexity: Checking availability requires JOIN operations or subqueries between rules and exceptions tables; these queries grow increasingly complex as rules, exceptions, and validation requirements expand.

  • Approach 2: Cron Expressions

    Cron expressions are widely used in operating systems and task schedulers (such as Laravel Task Scheduling or NestJS Schedule).

    Strengths

    • Ideal for server tasks: Designed specifically for executing background processes (Background Jobs) with high efficiency.
    • Broad ecosystem support: Supported natively across Unix/Linux environments, with libraries available in virtually all modern programming ecosystems.

    Weaknesses

  • Unsuitable for human calendar logic: Cron is designed for operational task execution, not for modeling complex human event rules, especially when timezones, Daylight Saving Time (DST) transitions, and exception dates enter the equation.
  • Lack of termination conditions and exceptions: You cannot easily express rules like "repeat this event only 5 times" or "exclude public holidays" in a standard Cron expression.

  • Approach 3: iCalendar RRULE Standard (RFC 5545)

    When building calendar systems requiring human interaction, iCalendar (RFC 5545) is the established industry standard for defining recurrence patterns. This standard uses a property called Recurrence Rule (RRULE) to represent a wide array of temporal patterns within a single string.

    RRULE Standard Recurrence Rule (RRULE) Example
    FREQ=WEEKLY;BYDAY=MO,WE;UNTIL=20241231T000000Z

    Weekly recurrence on Mondays and Wednesdays until the end of 2024.

    How Does It Work Architecturally?

    Rather than persisting every future occurrence as an independent database record, the system stores the RRULE string in the database. Occurrences are then parsed and generated on demand using dedicated libraries.

    PHP
    // Migration for the RRULE-based schema
    Schema::create('trainer_schedules', function (Blueprint $table) {
        $table->id();
        $table->foreignId('trainer_id')->constrained('trainers')->onDelete('cascade');
    
        // The anchor date-time for the recurrence (When does this pattern start?)
        $table->dateTime('dtstart');
    
        // Store the RFC 5545 recurrence rule as a string
        $table->string('rrule_string');
    
        // Duration of the event in minutes (e.g., a 60-minute session)
        $table->integer('duration_minutes');
    
        // The timezone in which the recurring event is conceptually anchored
        $table->string('timezone');
    
        $table->timestamps();
    });
    

    In this architecture, DTSTART and timezone are stored as distinct database fields rather than embedding DTSTART within the raw string. dtstart is then passed to the php-rrule library when instantiating the rule object. This guarantees clean database queryability and explicit timezone handling.

    Architectural Timezone Handling

    Relying solely on database data types like timeTz does not resolve timezone complexities in booking systems. In a production system, you must architecturally distinguish between:

    1. Local Event Time: 9:00 AM as perceived by the user.
    2. Timezone Identifier: Africa/Algiers, which dictates when Daylight Saving Time (DST) begins or ends.
    3. Absolute Moment (UTC Instant): The actual point in time stored for conflict detection and cross-system synchronization.
    4. Recurrence Rule (RRULE): The pattern specification anchored by DTSTART and the timezone during occurrence evaluation.

    When checking availability, a specialized library (such as rlanvin/php-rrule in PHP) is utilized:

    PHP
    use RRule\RRule;
    use Carbon\Carbon;
    use App\Models\TrainerSchedule;
    
    // 1. Fetch the schedule from the database (includes rrule + timezone)
    $schedule = TrainerSchedule::where('trainer_id', $trainerId)->first();
    
    // 2. Build the RRULE using dtstart from the DB, so the recurrence is
    //    anchored to the correct start date and interpreted in the right timezone.
    $rrule = new RRule(
        $schedule->rrule_string,
        new DateTime($schedule->dtstart, new DateTimeZone($schedule->timezone))
    );
    
    // 3. Parse the requested date in the schedule's own timezone.
    //    This is critical: a booking for "9 AM" means different UTC moments
    //    in different timezones. We must interpret the time in the schedule's timezone.
    $requestedDate = Carbon::parse(
        '2023-11-15 09:00:00',
        $schedule->timezone
    );
    
    // 4. Check whether the requested date-time is an occurrence of the recurrence rule.
    if ($rrule->occursAt($requestedDate)) {
        // The requested time matches the recurrence rule.
        // Availability still requires checking exceptions and existing bookings.
        return response()->json(['status' => 'matches_schedule']);
    }
    

    Strengths

    • Maximum Expressiveness: Supports extremely complex patterns (e.g., "the last Thursday of every month") effortlessly.
    • Interoperability: Standardizing on RFC 5545 simplifies integration and sync with external calendar platforms such as Google Calendar, Apple Calendar, and Outlook (subject to feature support variations across providers).
    • Reduced Storage Overhead: Preserves a single pattern rule instead of thousands of generated future instances, significantly reducing database storage based on query requirements.

    Weaknesses

  • Complex Direct SQL Querying: You cannot execute a simple SQL query to retrieve "all trainers available on Tuesday." You must fetch rules and evaluate them programmatically or maintain pre-calculated materialized caches.
  • External Dependency: Requires external parsing and expansion libraries to handle RFC string evaluation safely.

  • Anatomy of RRULE Properties

    From an architectural standpoint, hardcoding RRULE strings manually is error-prone. Modern libraries (such as rlanvin/php-rrule in PHP) provide array-based configuration interfaces or fluent builders to assemble rules dynamically before persisting them as standard RFC strings.

    Here is a comprehensive example demonstrating how to construct an advanced rule: "Monthly recurrence on Mondays and Wednesdays, selecting only the last matching day, ending at the end of the year":

    PHP
    use RRule\RRule;
    
    $rrule = new RRule([
        'FREQ'       => 'MONTHLY',
        'INTERVAL'   => 1,
        'DTSTART'    => new DateTime(
            '2023-11-01 00:00:00',
            new DateTimeZone('UTC')
        ), // The anchor date for the recurrence
        'BYDAY'      => ['MO', 'WE'],    // Filter: Generate all Mondays and Wednesdays
        'BYSETPOS'   => -1,              // Filter: Pick only the last occurrence from the generated set
        'UNTIL'      => '20241231T235959Z'     // End condition
    ]);
    
    // Convert the object to a standard RFC 5545 string to store in the DB
    $rruleString = $rrule->rfcString();
    // Output: FREQ=MONTHLY;UNTIL=20241231T235959Z;BYDAY=MO,WE;BYSETPOS=-1
    
    RRULE Generated Rule for Advanced Example
    FREQ=MONTHLY;UNTIL=20241231T235959Z;BYDAY=MO,WE;BYSETPOS=-1

    Monthly recurrence, selecting the last Monday or Wednesday of the month, until the end of 2024.

    Pay Attention to BYSETPOS Behavior

    The example above does not mean "the last Monday and the last Wednesday of the month" — it represents a very different behavior:

    1. The engine first generates all Mondays and Wednesdays within the month.
    2. BYSETPOS=-1 then selects only the last single date from that entire resulting array.

    For example, in November 2023, the matching days are: Mon Nov 6, Wed Nov 8, Mon Nov 13, Wed Nov 15, Mon Nov 20, Wed Nov 22, Mon Nov 27, Wed Nov 29. The rule will select only Wednesday, November 29, because it is the final date in the set.

    Tip

    For detailed documentation on object instantiation and parameter mapping, consult the official php-rrule repository.

    rlanvin/php-rrule on GitHub

    To master this technology, you must understand the core properties defined in iCalendar (RFC 5545):

    1. Core Frequency: FREQ and INTERVAL

    • FREQ (Frequency): The only mandatory property. Specifies the base recurrence interval (YEARLY, MONTHLY, WEEKLY, DAILY, HOURLY, MINUTELY, SECONDLY).
    • INTERVAL: Acts as a frequency multiplier. FREQ=WEEKLY;INTERVAL=2 means "every 2 weeks." Defaults to 1.

    2. Anchor Point: DTSTART

    In RFC 5545, DTSTART defines the start time of the recurrence set. Certain libraries like php-rrule enforce specific runtime behaviors, so ensuring DTSTART aligns with your rule semantics is crucial. It also carries temporal context missing from the rule string itself, such as start time of day.

    3. Filtering Rules: BY-Rules

    This is where the true power of RRULE resides, allowing you to refine or expand instances within the frequency cycle:

    • BYDAY: Target specific days of the week (MO, TU, WE...). Can be prefixed with numbers to specify positional days (1FR = first Friday, -1TH = last Thursday). Note that BYDAY behavior depends on the FREQ context.
    • BYMONTHDAY: Target specific days of the month (1 to 31). Negative values count backward (-1 = last day of the month).
    • BYMONTH: Restrict evaluation to specific months (1 to 12).
    • BYSETPOS: An advanced positional index operating on the generated set. BYSETPOS=3 extracts only the 3rd matching occurrence from the generated group.

    4. Termination Rules

    Prevents infinite iteration:

    • COUNT: Terminates recurrence after a fixed number of occurrences (e.g., FREQ=WEEKLY;COUNT=10).
    • UNTIL: Terminates recurrence at a specific timestamp. (Architectural Note: RFC 5545 explicitly forbids combining COUNT and UNTIL in the same rule).

    The Architectural Layer: Beyond RRULE

    A vital engineering distinction often overlooked: storing an RRULE does not mean saving every single slot as an individual database record. The RRULE defines the rule specification, while dates calculated from it are Generated Occurrences. Systems can generate these instances on the fly or pre-generate a bounded future window for indexing and performance. In both cases, persistent records are created only for exceptions, actual bookings, or modified slots.

    Consequently, an RRULE describes only the core template. A complete production system requires a full availability pipeline:

    1. Occurrence Generation: Expand the RRULE for a specific query window.
    2. Exception Filtering: Exclude blacklisted dates or holiday exceptions.
    3. Booking Verification: Cross-reference existing reservations.
    4. Buffer & Constraint Checks: Apply buffer times, max capacity, and booking lead-time limits.
    5. Slot Output: Return final available slots to the client.

    This processing pipeline is what transforms a simple RRULE parser into a robust enterprise scheduling engine.


    How to Decide as a Software Architect?

    There is no silver bullet; the optimal choice depends entirely on your system domain:

    1. Choose Cron Jobs when building system-level background routines (database backups, scheduled report distribution, batch email queues).
    2. Choose Custom Database Schema if your domain requires basic, static schedules (e.g., fixed weekly shifts without external calendar sync) and direct SQL query performance is your top priority.
    3. Choose RRULE (RFC 5545) when building human-centric scheduling applications (booking platforms, clinic appointments, event management), especially where requirements will evolve or require interoperability with standard calendar clients.

    Note: Cron and RRULE Are Not Mutually Exclusive

    Developers often view this as a binary choice between Cron and RRULE. In complex architectures, they complement each other:

    • RRULE defines the business rule → A Cron Job executes periodically → Reads active RRULEs → Pre-generates upcoming slots → Pushes jobs to a queue.
    • Or: A user requests a booking → System checks RRULE validity directly → On match, dispatches confirmation via queue worker.

    In short: RRULE is the "rule description language," while Cron is the "execution trigger." Both play distinct, complementary roles in modern software architecture.

    As software engineers, our goal is to evaluate existing standards and avoid reinventing complex wheels when battle-tested industry specifications like RFC 5545 are readily available.

    Share This Article