[{"data":1,"prerenderedAt":1170},["ShallowReactive",2],{"global-data":3,"article-recurring-events-architecture-database-cron-rrule-en":13},{"id":4,"cvUrl":5,"email":6,"extension":7,"github":8,"linkedin":8,"meta":9,"phone":10,"stem":11,"__hash__":12},"global\u002Fglobal.json","\u002Fcv\u002FAkram_Zaitout.pdf","akramzaitout@gmail.com","json","akramzaitout",{},null,"global","4TefImedu35OzCU90dRA6cnRTwh7NN57Hg1_a3FxWQo",{"id":14,"title":15,"body":16,"category":1156,"coverImage":1157,"description":1160,"excerpt":1160,"extension":1161,"isFeatured":81,"meta":1162,"navigation":81,"path":1163,"publishedAt":1164,"readingTime":91,"seo":1165,"slug":1167,"stem":1168,"updatedAt":10,"__hash__":1169},"blog\u002Fblog\u002Fen\u002Frecurring-events-architecture.md","How to Build a Recurring Events System? Database Schema vs Cron vs RRULE",{"type":17,"value":18,"toc":1148},"minimark",[19,23,26,29,34,37,40,173,214,216,220,223,255,257,261,276,284,289,296,387,409,455,466,613,651,653,657,669,677,773,780,813,823,829,839,887,893,908,915,921,987,991,994,1023,1025,1029,1047,1054,1086,1089,1091,1095,1098,1118,1141,1144],[20,21,22],"p",{},"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.",[20,24,25],{},"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.",[27,28],"hr",{},[30,31,33],"h3",{"id":32},"approach-1-custom-database-schema","Approach 1: Custom Database Schema",[20,35,36],{},"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.",[20,38,39],{},"Here, we will use a trainer availability model as a practical example of custom recurrence representation inside a database.",[41,42,47],"pre",{"className":43,"code":44,"language":45,"meta":46,"style":46},"language-php shiki shiki-themes github-light github-dark","\u002F\u002F Migration for the initial availability schema\nSchema::create('trainer_availability', function (Blueprint $table) {\n    $table->id();\n    $table->foreignId('trainer_id')->constrained('trainers')->onDelete('cascade');\n\n    \u002F\u002F Storing the day as an integer (e.g., 0 for Sunday, 1 for Monday)\n    $table->integer('day_of_week')->unsigned()->default(0);\n    $table->timeTz('start_time');\n    $table->timeTz('end_time');\n\n    $table->timestamps();\n});\n\n\u002F\u002F Migration for exceptions (e.g., holidays, sick leaves)\nSchema::create('trainer_availability_exceptions', function (Blueprint $table) {\n    $table->id();\n    $table->foreignId('trainer_id')->constrained('trainers')->onDelete('cascade');\n    $table->enum('type', ['available', 'unavailable']);\n    $table->date('date');\n    $table->timestamps();\n});\n","php","",[48,49,50,58,64,70,76,83,89,95,101,107,112,118,124,129,135,141,146,151,157,163,168],"code",{"__ignoreMap":46},[51,52,55],"span",{"class":53,"line":54},"line",1,[51,56,57],{},"\u002F\u002F Migration for the initial availability schema\n",[51,59,61],{"class":53,"line":60},2,[51,62,63],{},"Schema::create('trainer_availability', function (Blueprint $table) {\n",[51,65,67],{"class":53,"line":66},3,[51,68,69],{},"    $table->id();\n",[51,71,73],{"class":53,"line":72},4,[51,74,75],{},"    $table->foreignId('trainer_id')->constrained('trainers')->onDelete('cascade');\n",[51,77,79],{"class":53,"line":78},5,[51,80,82],{"emptyLinePlaceholder":81},true,"\n",[51,84,86],{"class":53,"line":85},6,[51,87,88],{},"    \u002F\u002F Storing the day as an integer (e.g., 0 for Sunday, 1 for Monday)\n",[51,90,92],{"class":53,"line":91},7,[51,93,94],{},"    $table->integer('day_of_week')->unsigned()->default(0);\n",[51,96,98],{"class":53,"line":97},8,[51,99,100],{},"    $table->timeTz('start_time');\n",[51,102,104],{"class":53,"line":103},9,[51,105,106],{},"    $table->timeTz('end_time');\n",[51,108,110],{"class":53,"line":109},10,[51,111,82],{"emptyLinePlaceholder":81},[51,113,115],{"class":53,"line":114},11,[51,116,117],{},"    $table->timestamps();\n",[51,119,121],{"class":53,"line":120},12,[51,122,123],{},"});\n",[51,125,127],{"class":53,"line":126},13,[51,128,82],{"emptyLinePlaceholder":81},[51,130,132],{"class":53,"line":131},14,[51,133,134],{},"\u002F\u002F Migration for exceptions (e.g., holidays, sick leaves)\n",[51,136,138],{"class":53,"line":137},15,[51,139,140],{},"Schema::create('trainer_availability_exceptions', function (Blueprint $table) {\n",[51,142,144],{"class":53,"line":143},16,[51,145,69],{},[51,147,149],{"class":53,"line":148},17,[51,150,75],{},[51,152,154],{"class":53,"line":153},18,[51,155,156],{},"    $table->enum('type', ['available', 'unavailable']);\n",[51,158,160],{"class":53,"line":159},19,[51,161,162],{},"    $table->date('date');\n",[51,164,166],{"class":53,"line":165},20,[51,167,117],{},[51,169,171],{"class":53,"line":170},21,[51,172,123],{},[174,175,176,196],"pros-cons-table",{},[177,178,179],"template",{"v-slot:pros":46},[180,181,182,190],"ul",{},[183,184,185,189],"li",{},[186,187,188],"strong",{},"Simplicity of initial queries:"," You can easily use standard SQL queries to search for appointments on a specific day.",[183,191,192,195],{},[186,193,194],{},"No dependency on a dedicated recurrence library:"," Core recurrence logic is built directly into the system (even if you rely on date\u002Ftime utilities).",[177,197,198,204],{"v-slot:cons":46},[183,199,200,203],{},[186,201,202],{},"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.",[183,205,206,209,210,213],{},[186,207,208],{},"Escalating query complexity:"," Checking availability requires ",[48,211,212],{},"JOIN"," operations or subqueries between rules and exceptions tables; these queries grow increasingly complex as rules, exceptions, and validation requirements expand.",[27,215],{},[30,217,219],{"id":218},"approach-2-cron-expressions","Approach 2: Cron Expressions",[20,221,222],{},"Cron expressions are widely used in operating systems and task schedulers (such as Laravel Task Scheduling or NestJS Schedule).",[174,224,225,241],{},[177,226,227],{"v-slot:pros":46},[180,228,229,235],{},[183,230,231,234],{},[186,232,233],{},"Ideal for server tasks:"," Designed specifically for executing background processes (Background Jobs) with high efficiency.",[183,236,237,240],{},[186,238,239],{},"Broad ecosystem support:"," Supported natively across Unix\u002FLinux environments, with libraries available in virtually all modern programming ecosystems.",[177,242,243,249],{"v-slot:cons":46},[183,244,245,248],{},[186,246,247],{},"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.",[183,250,251,254],{},[186,252,253],{},"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.",[27,256],{},[30,258,260],{"id":259},"approach-3-icalendar-rrule-standard-rfc-5545","Approach 3: iCalendar RRULE Standard (RFC 5545)",[20,262,263,264,271,272,275],{},"When building calendar systems requiring human interaction, ",[265,266,270],"a",{"href":267,"rel":268},"https:\u002F\u002Ficalendar.org\u002FRFC-Specifications\u002FiCalendar-RFC-5545\u002F",[269],"nofollow","iCalendar (RFC 5545)"," is the established industry standard for defining recurrence patterns. This standard uses a property called ",[186,273,274],{},"Recurrence Rule (RRULE)"," to represent a wide array of temporal patterns within a single string.",[277,278,281],"rrule-example",{"rule":279,"title":280},"FREQ=WEEKLY;BYDAY=MO,WE;UNTIL=20241231T000000Z","Standard Recurrence Rule (RRULE) Example",[20,282,283],{},"Weekly recurrence on Mondays and Wednesdays until the end of 2024.",[285,286,288],"h4",{"id":287},"how-does-it-work-architecturally","How Does It Work Architecturally?",[20,290,291,292,295],{},"Rather than persisting every future occurrence as an independent database record, the system stores the ",[48,293,294],{},"RRULE"," string in the database. Occurrences are then parsed and generated on demand using dedicated libraries.",[41,297,299],{"className":43,"code":298,"language":45,"meta":46,"style":46},"\u002F\u002F Migration for the RRULE-based schema\nSchema::create('trainer_schedules', function (Blueprint $table) {\n    $table->id();\n    $table->foreignId('trainer_id')->constrained('trainers')->onDelete('cascade');\n\n    \u002F\u002F The anchor date-time for the recurrence (When does this pattern start?)\n    $table->dateTime('dtstart');\n\n    \u002F\u002F Store the RFC 5545 recurrence rule as a string\n    $table->string('rrule_string');\n\n    \u002F\u002F Duration of the event in minutes (e.g., a 60-minute session)\n    $table->integer('duration_minutes');\n\n    \u002F\u002F The timezone in which the recurring event is conceptually anchored\n    $table->string('timezone');\n\n    $table->timestamps();\n});\n",[48,300,301,306,311,315,319,323,328,333,337,342,347,351,356,361,365,370,375,379,383],{"__ignoreMap":46},[51,302,303],{"class":53,"line":54},[51,304,305],{},"\u002F\u002F Migration for the RRULE-based schema\n",[51,307,308],{"class":53,"line":60},[51,309,310],{},"Schema::create('trainer_schedules', function (Blueprint $table) {\n",[51,312,313],{"class":53,"line":66},[51,314,69],{},[51,316,317],{"class":53,"line":72},[51,318,75],{},[51,320,321],{"class":53,"line":78},[51,322,82],{"emptyLinePlaceholder":81},[51,324,325],{"class":53,"line":85},[51,326,327],{},"    \u002F\u002F The anchor date-time for the recurrence (When does this pattern start?)\n",[51,329,330],{"class":53,"line":91},[51,331,332],{},"    $table->dateTime('dtstart');\n",[51,334,335],{"class":53,"line":97},[51,336,82],{"emptyLinePlaceholder":81},[51,338,339],{"class":53,"line":103},[51,340,341],{},"    \u002F\u002F Store the RFC 5545 recurrence rule as a string\n",[51,343,344],{"class":53,"line":109},[51,345,346],{},"    $table->string('rrule_string');\n",[51,348,349],{"class":53,"line":114},[51,350,82],{"emptyLinePlaceholder":81},[51,352,353],{"class":53,"line":120},[51,354,355],{},"    \u002F\u002F Duration of the event in minutes (e.g., a 60-minute session)\n",[51,357,358],{"class":53,"line":126},[51,359,360],{},"    $table->integer('duration_minutes');\n",[51,362,363],{"class":53,"line":131},[51,364,82],{"emptyLinePlaceholder":81},[51,366,367],{"class":53,"line":137},[51,368,369],{},"    \u002F\u002F The timezone in which the recurring event is conceptually anchored\n",[51,371,372],{"class":53,"line":143},[51,373,374],{},"    $table->string('timezone');\n",[51,376,377],{"class":53,"line":148},[51,378,82],{"emptyLinePlaceholder":81},[51,380,381],{"class":53,"line":153},[51,382,117],{},[51,384,385],{"class":53,"line":159},[51,386,123],{},[20,388,389,390,393,394,397,398,400,401,404,405,408],{},"In this architecture, ",[48,391,392],{},"DTSTART"," and ",[48,395,396],{},"timezone"," are stored as distinct database fields rather than embedding ",[48,399,392],{}," within the raw string. ",[48,402,403],{},"dtstart"," is then passed to the ",[48,406,407],{},"php-rrule"," library when instantiating the rule object. This guarantees clean database queryability and explicit timezone handling.",[410,411,414,421],"prose-callout",{"type":412,"title":413},"warning","Architectural Timezone Handling",[20,415,416,417,420],{},"Relying solely on database data types like ",[48,418,419],{},"timeTz"," does not resolve timezone complexities in booking systems. In a production system, you must architecturally distinguish between:",[422,423,424,430,440,446],"ol",{},[183,425,426,429],{},[186,427,428],{},"Local Event Time:"," 9:00 AM as perceived by the user.",[183,431,432,435,436,439],{},[186,433,434],{},"Timezone Identifier:"," ",[48,437,438],{},"Africa\u002FAlgiers",", which dictates when Daylight Saving Time (DST) begins or ends.",[183,441,442,445],{},[186,443,444],{},"Absolute Moment (UTC Instant):"," The actual point in time stored for conflict detection and cross-system synchronization.",[183,447,448,451,452,454],{},[186,449,450],{},"Recurrence Rule (RRULE):"," The pattern specification anchored by ",[48,453,392],{}," and the timezone during occurrence evaluation.",[20,456,457,458,465],{},"When checking availability, a specialized library (such as ",[265,459,462],{"href":460,"rel":461},"https:\u002F\u002Fgithub.com\u002Frlanvin\u002Fphp-rrule",[269],[48,463,464],{},"rlanvin\u002Fphp-rrule"," in PHP) is utilized:",[41,467,469],{"className":43,"code":468,"language":45,"meta":46,"style":46},"use RRule\\RRule;\nuse Carbon\\Carbon;\nuse App\\Models\\TrainerSchedule;\n\n\u002F\u002F 1. Fetch the schedule from the database (includes rrule + timezone)\n$schedule = TrainerSchedule::where('trainer_id', $trainerId)->first();\n\n\u002F\u002F 2. Build the RRULE using dtstart from the DB, so the recurrence is\n\u002F\u002F    anchored to the correct start date and interpreted in the right timezone.\n$rrule = new RRule(\n    $schedule->rrule_string,\n    new DateTime($schedule->dtstart, new DateTimeZone($schedule->timezone))\n);\n\n\u002F\u002F 3. Parse the requested date in the schedule's own timezone.\n\u002F\u002F    This is critical: a booking for \"9 AM\" means different UTC moments\n\u002F\u002F    in different timezones. We must interpret the time in the schedule's timezone.\n$requestedDate = Carbon::parse(\n    '2023-11-15 09:00:00',\n    $schedule->timezone\n);\n\n\u002F\u002F 4. Check whether the requested date-time is an occurrence of the recurrence rule.\nif ($rrule->occursAt($requestedDate)) {\n    \u002F\u002F The requested time matches the recurrence rule.\n    \u002F\u002F Availability still requires checking exceptions and existing bookings.\n    return response()->json(['status' => 'matches_schedule']);\n}\n",[48,470,471,476,481,486,490,495,500,504,509,514,519,524,529,534,538,543,548,553,558,563,568,572,577,583,589,595,601,607],{"__ignoreMap":46},[51,472,473],{"class":53,"line":54},[51,474,475],{},"use RRule\\RRule;\n",[51,477,478],{"class":53,"line":60},[51,479,480],{},"use Carbon\\Carbon;\n",[51,482,483],{"class":53,"line":66},[51,484,485],{},"use App\\Models\\TrainerSchedule;\n",[51,487,488],{"class":53,"line":72},[51,489,82],{"emptyLinePlaceholder":81},[51,491,492],{"class":53,"line":78},[51,493,494],{},"\u002F\u002F 1. Fetch the schedule from the database (includes rrule + timezone)\n",[51,496,497],{"class":53,"line":85},[51,498,499],{},"$schedule = TrainerSchedule::where('trainer_id', $trainerId)->first();\n",[51,501,502],{"class":53,"line":91},[51,503,82],{"emptyLinePlaceholder":81},[51,505,506],{"class":53,"line":97},[51,507,508],{},"\u002F\u002F 2. Build the RRULE using dtstart from the DB, so the recurrence is\n",[51,510,511],{"class":53,"line":103},[51,512,513],{},"\u002F\u002F    anchored to the correct start date and interpreted in the right timezone.\n",[51,515,516],{"class":53,"line":109},[51,517,518],{},"$rrule = new RRule(\n",[51,520,521],{"class":53,"line":114},[51,522,523],{},"    $schedule->rrule_string,\n",[51,525,526],{"class":53,"line":120},[51,527,528],{},"    new DateTime($schedule->dtstart, new DateTimeZone($schedule->timezone))\n",[51,530,531],{"class":53,"line":126},[51,532,533],{},");\n",[51,535,536],{"class":53,"line":131},[51,537,82],{"emptyLinePlaceholder":81},[51,539,540],{"class":53,"line":137},[51,541,542],{},"\u002F\u002F 3. Parse the requested date in the schedule's own timezone.\n",[51,544,545],{"class":53,"line":143},[51,546,547],{},"\u002F\u002F    This is critical: a booking for \"9 AM\" means different UTC moments\n",[51,549,550],{"class":53,"line":148},[51,551,552],{},"\u002F\u002F    in different timezones. We must interpret the time in the schedule's timezone.\n",[51,554,555],{"class":53,"line":153},[51,556,557],{},"$requestedDate = Carbon::parse(\n",[51,559,560],{"class":53,"line":159},[51,561,562],{},"    '2023-11-15 09:00:00',\n",[51,564,565],{"class":53,"line":165},[51,566,567],{},"    $schedule->timezone\n",[51,569,570],{"class":53,"line":170},[51,571,533],{},[51,573,575],{"class":53,"line":574},22,[51,576,82],{"emptyLinePlaceholder":81},[51,578,580],{"class":53,"line":579},23,[51,581,582],{},"\u002F\u002F 4. Check whether the requested date-time is an occurrence of the recurrence rule.\n",[51,584,586],{"class":53,"line":585},24,[51,587,588],{},"if ($rrule->occursAt($requestedDate)) {\n",[51,590,592],{"class":53,"line":591},25,[51,593,594],{},"    \u002F\u002F The requested time matches the recurrence rule.\n",[51,596,598],{"class":53,"line":597},26,[51,599,600],{},"    \u002F\u002F Availability still requires checking exceptions and existing bookings.\n",[51,602,604],{"class":53,"line":603},27,[51,605,606],{},"    return response()->json(['status' => 'matches_schedule']);\n",[51,608,610],{"class":53,"line":609},28,[51,611,612],{},"}\n",[174,614,615,637],{},[177,616,617],{"v-slot:pros":46},[180,618,619,625,631],{},[183,620,621,624],{},[186,622,623],{},"Maximum Expressiveness:"," Supports extremely complex patterns (e.g., \"the last Thursday of every month\") effortlessly.",[183,626,627,630],{},[186,628,629],{},"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).",[183,632,633,636],{},[186,634,635],{},"Reduced Storage Overhead:"," Preserves a single pattern rule instead of thousands of generated future instances, significantly reducing database storage based on query requirements.",[177,638,639,645],{"v-slot:cons":46},[183,640,641,644],{},[186,642,643],{},"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.",[183,646,647,650],{},[186,648,649],{},"External Dependency:"," Requires external parsing and expansion libraries to handle RFC string evaluation safely.",[27,652],{},[30,654,656],{"id":655},"anatomy-of-rrule-properties","Anatomy of RRULE Properties",[20,658,659,660,662,663,668],{},"From an architectural standpoint, hardcoding ",[48,661,294],{}," strings manually is error-prone. Modern libraries (such as ",[265,664,666],{"href":460,"rel":665},[269],[48,667,464],{}," in PHP) provide array-based configuration interfaces or fluent builders to assemble rules dynamically before persisting them as standard RFC strings.",[20,670,671,672,676],{},"Here is a comprehensive example demonstrating how to construct an advanced rule: ",[673,674,675],"em",{},"\"Monthly recurrence on Mondays and Wednesdays, selecting only the last matching day, ending at the end of the year\"",":",[41,678,680],{"className":43,"code":679,"language":45,"meta":46,"style":46},"use RRule\\RRule;\n\n$rrule = new RRule([\n    'FREQ'       => 'MONTHLY',\n    'INTERVAL'   => 1,\n    'DTSTART'    => new DateTime(\n        '2023-11-01 00:00:00',\n        new DateTimeZone('UTC')\n    ), \u002F\u002F The anchor date for the recurrence\n    'BYDAY'      => ['MO', 'WE'],    \u002F\u002F Filter: Generate all Mondays and Wednesdays\n    'BYSETPOS'   => -1,              \u002F\u002F Filter: Pick only the last occurrence from the generated set\n    'UNTIL'      => '20241231T235959Z'     \u002F\u002F End condition\n]);\n\n\u002F\u002F Convert the object to a standard RFC 5545 string to store in the DB\n$rruleString = $rrule->rfcString();\n\u002F\u002F Output: FREQ=MONTHLY;UNTIL=20241231T235959Z;BYDAY=MO,WE;BYSETPOS=-1\n",[48,681,682,686,690,695,700,705,710,715,720,725,733,741,749,754,758,763,768],{"__ignoreMap":46},[51,683,684],{"class":53,"line":54},[51,685,475],{},[51,687,688],{"class":53,"line":60},[51,689,82],{"emptyLinePlaceholder":81},[51,691,692],{"class":53,"line":66},[51,693,694],{},"$rrule = new RRule([\n",[51,696,697],{"class":53,"line":72},[51,698,699],{},"    'FREQ'       => 'MONTHLY',\n",[51,701,702],{"class":53,"line":78},[51,703,704],{},"    'INTERVAL'   => 1,\n",[51,706,707],{"class":53,"line":85},[51,708,709],{},"    'DTSTART'    => new DateTime(\n",[51,711,712],{"class":53,"line":91},[51,713,714],{},"        '2023-11-01 00:00:00',\n",[51,716,717],{"class":53,"line":97},[51,718,719],{},"        new DateTimeZone('UTC')\n",[51,721,722],{"class":53,"line":103},[51,723,724],{},"    ), \u002F\u002F The anchor date for the recurrence\n",[51,726,727,730],{"class":53,"line":109},[51,728,729],{},"    'BYDAY'      => ['MO', 'WE'],",[51,731,732],{},"    \u002F\u002F Filter: Generate all Mondays and Wednesdays\n",[51,734,735,738],{"class":53,"line":114},[51,736,737],{},"    'BYSETPOS'   => -1,",[51,739,740],{},"              \u002F\u002F Filter: Pick only the last occurrence from the generated set\n",[51,742,743,746],{"class":53,"line":120},[51,744,745],{},"    'UNTIL'      => '20241231T235959Z'",[51,747,748],{},"     \u002F\u002F End condition\n",[51,750,751],{"class":53,"line":126},[51,752,753],{},"]);\n",[51,755,756],{"class":53,"line":131},[51,757,82],{"emptyLinePlaceholder":81},[51,759,760],{"class":53,"line":137},[51,761,762],{},"\u002F\u002F Convert the object to a standard RFC 5545 string to store in the DB\n",[51,764,765],{"class":53,"line":143},[51,766,767],{},"$rruleString = $rrule->rfcString();\n",[51,769,770],{"class":53,"line":148},[51,771,772],{},"\u002F\u002F Output: FREQ=MONTHLY;UNTIL=20241231T235959Z;BYDAY=MO,WE;BYSETPOS=-1\n",[277,774,777],{"rule":775,"title":776},"FREQ=MONTHLY;UNTIL=20241231T235959Z;BYDAY=MO,WE;BYSETPOS=-1","Generated Rule for Advanced Example",[20,778,779],{},"Monthly recurrence, selecting the last Monday or Wednesday of the month, until the end of 2024.",[410,781,784,791,806],{"type":782,"title":783},"info","Pay Attention to BYSETPOS Behavior",[20,785,786,787,790],{},"The example above ",[186,788,789],{},"does not"," mean \"the last Monday and the last Wednesday of the month\" — it represents a very different behavior:",[422,792,793,796],{},[183,794,795],{},"The engine first generates all Mondays and Wednesdays within the month.",[183,797,798,801,802,805],{},[48,799,800],{},"BYSETPOS=-1"," then selects ",[186,803,804],{},"only the last single date"," from that entire resulting array.",[20,807,808,809,812],{},"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 ",[186,810,811],{},"only Wednesday, November 29",", because it is the final date in the set.",[410,814,817],{"type":815,"href":460,"link-label":816},"tip","rlanvin\u002Fphp-rrule on GitHub",[20,818,819,820,822],{},"For detailed documentation on object instantiation and parameter mapping, consult the official ",[48,821,407],{}," repository.",[20,824,825,826,676],{},"To master this technology, you must understand the core properties defined in ",[265,827,270],{"href":267,"rel":828},[269],[285,830,832,833,393,836],{"id":831},"_1-core-frequency-freq-and-interval","1. Core Frequency: ",[48,834,835],{},"FREQ",[48,837,838],{},"INTERVAL",[180,840,841,872],{},[183,842,843,848,849,852,853,852,856,852,859,852,862,852,865,852,868,871],{},[186,844,845,847],{},[48,846,835],{}," (Frequency):"," The only mandatory property. Specifies the base recurrence interval (",[48,850,851],{},"YEARLY",", ",[48,854,855],{},"MONTHLY",[48,857,858],{},"WEEKLY",[48,860,861],{},"DAILY",[48,863,864],{},"HOURLY",[48,866,867],{},"MINUTELY",[48,869,870],{},"SECONDLY",").",[183,873,874,878,879,882,883,886],{},[186,875,876,676],{},[48,877,838],{}," Acts as a frequency multiplier. ",[48,880,881],{},"FREQ=WEEKLY;INTERVAL=2"," means \"every 2 weeks.\" Defaults to ",[48,884,885],{},"1",".",[285,888,890,891],{"id":889},"_2-anchor-point-dtstart","2. Anchor Point: ",[48,892,392],{},[20,894,895,896,852,899,901,902,904,905,907],{},"In ",[48,897,898],{},"RFC 5545",[48,900,392],{}," defines the start time of the recurrence set. Certain libraries like ",[48,903,407],{}," enforce specific runtime behaviors, so ensuring ",[48,906,392],{}," aligns with your rule semantics is crucial. It also carries temporal context missing from the rule string itself, such as start time of day.",[285,909,911,912],{"id":910},"_3-filtering-rules-by-rules","3. Filtering Rules: ",[48,913,914],{},"BY-Rules",[20,916,917,918,920],{},"This is where the true power of ",[48,919,294],{}," resides, allowing you to refine or expand instances within the frequency cycle:",[180,922,923,955,967,975],{},[183,924,925,930,931,852,934,852,937,940,941,944,945,948,949,951,952,954],{},[186,926,927,676],{},[48,928,929],{},"BYDAY"," Target specific days of the week (",[48,932,933],{},"MO",[48,935,936],{},"TU",[48,938,939],{},"WE","...). Can be prefixed with numbers to specify positional days (",[48,942,943],{},"1FR"," = first Friday, ",[48,946,947],{},"-1TH"," = last Thursday). Note that ",[48,950,929],{}," behavior depends on the ",[48,953,835],{}," context.",[183,956,957,962,963,966],{},[186,958,959,676],{},[48,960,961],{},"BYMONTHDAY"," Target specific days of the month (1 to 31). Negative values count backward (",[48,964,965],{},"-1"," = last day of the month).",[183,968,969,974],{},[186,970,971,676],{},[48,972,973],{},"BYMONTH"," Restrict evaluation to specific months (1 to 12).",[183,976,977,982,983,986],{},[186,978,979,676],{},[48,980,981],{},"BYSETPOS"," An advanced positional index operating on the generated set. ",[48,984,985],{},"BYSETPOS=3"," extracts only the 3rd matching occurrence from the generated group.",[285,988,990],{"id":989},"_4-termination-rules","4. Termination Rules",[20,992,993],{},"Prevents infinite iteration:",[180,995,996,1007],{},[183,997,998,1003,1004,871],{},[186,999,1000,676],{},[48,1001,1002],{},"COUNT"," Terminates recurrence after a fixed number of occurrences (e.g., ",[48,1005,1006],{},"FREQ=WEEKLY;COUNT=10",[183,1008,1009,1014,1015],{},[186,1010,1011,676],{},[48,1012,1013],{},"UNTIL"," Terminates recurrence at a specific timestamp.\n",[673,1016,1017,1018,393,1020,1022],{},"(Architectural Note: RFC 5545 explicitly forbids combining ",[48,1019,1002],{},[48,1021,1013],{}," in the same rule).",[27,1024],{},[30,1026,1028],{"id":1027},"the-architectural-layer-beyond-rrule","The Architectural Layer: Beyond RRULE",[20,1030,1031,1032,1035,1036,1038,1039,1042,1043,1046],{},"A vital engineering distinction often overlooked: ",[186,1033,1034],{},"storing an RRULE does not mean saving every single slot as an individual database record."," The ",[48,1037,294],{}," defines the ",[673,1040,1041],{},"rule specification",", while dates calculated from it are ",[48,1044,1045],{},"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.",[20,1048,1049,1050,1053],{},"Consequently, an RRULE describes ",[186,1051,1052],{},"only the core template",". A complete production system requires a full availability pipeline:",[422,1055,1056,1062,1068,1074,1080],{},[183,1057,1058,1061],{},[186,1059,1060],{},"Occurrence Generation:"," Expand the RRULE for a specific query window.",[183,1063,1064,1067],{},[186,1065,1066],{},"Exception Filtering:"," Exclude blacklisted dates or holiday exceptions.",[183,1069,1070,1073],{},[186,1071,1072],{},"Booking Verification:"," Cross-reference existing reservations.",[183,1075,1076,1079],{},[186,1077,1078],{},"Buffer & Constraint Checks:"," Apply buffer times, max capacity, and booking lead-time limits.",[183,1081,1082,1085],{},[186,1083,1084],{},"Slot Output:"," Return final available slots to the client.",[20,1087,1088],{},"This processing pipeline is what transforms a simple RRULE parser into a robust enterprise scheduling engine.",[27,1090],{},[30,1092,1094],{"id":1093},"how-to-decide-as-a-software-architect","How to Decide as a Software Architect?",[20,1096,1097],{},"There is no silver bullet; the optimal choice depends entirely on your system domain:",[422,1099,1100,1106,1112],{},[183,1101,1102,1105],{},[186,1103,1104],{},"Choose Cron Jobs"," when building system-level background routines (database backups, scheduled report distribution, batch email queues).",[183,1107,1108,1111],{},[186,1109,1110],{},"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.",[183,1113,1114,1117],{},[186,1115,1116],{},"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.",[410,1119,1121,1124,1138],{"type":782,"title":1120},"Note: Cron and RRULE Are Not Mutually Exclusive",[20,1122,1123],{},"Developers often view this as a binary choice between Cron and RRULE. In complex architectures, they complement each other:",[180,1125,1126,1132],{},[183,1127,1128,1131],{},[186,1129,1130],{},"RRULE defines the business rule"," → A Cron Job executes periodically → Reads active RRULEs → Pre-generates upcoming slots → Pushes jobs to a queue.",[183,1133,1134,1137],{},[186,1135,1136],{},"Or:"," A user requests a booking → System checks RRULE validity directly → On match, dispatches confirmation via queue worker.",[20,1139,1140],{},"In short: RRULE is the \"rule description language,\" while Cron is the \"execution trigger.\" Both play distinct, complementary roles in modern software architecture.",[20,1142,1143],{},"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.",[1145,1146,1147],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":46,"searchDepth":60,"depth":60,"links":1149},[1150,1151,1152,1153,1154,1155],{"id":32,"depth":66,"text":33},{"id":218,"depth":66,"text":219},{"id":259,"depth":66,"text":260},{"id":655,"depth":66,"text":656},{"id":1027,"depth":66,"text":1028},{"id":1093,"depth":66,"text":1094},"tech",{"url":1158,"alternativeText":1159},"https:\u002F\u002Fimages.unsplash.com\u002Fphoto-1506784983877-45594efa4cbe","Calendar and clock","A technical article comparing three architectural approaches to recurring event scheduling: custom database schemas, Cron jobs, and the RRULE standard, with practical implementation guidelines.","md",{},"\u002Fblog\u002Fen\u002Frecurring-events-architecture","2026-08-13T00:00:00Z",{"metaTitle":15,"metaDescription":1166,"title":15,"description":1160},"An engineering guide to building recurring event systems, comparing RRULE, Cron, and custom database schemas.","recurring-events-architecture-database-cron-rrule","blog\u002Fen\u002Frecurring-events-architecture","j1kXP7N5UYhTDZSqHtmgo-vYtprCl91XaqxkoyPQ8qA",1787429516299]