簡單分享 PHP RRule 套件處理重複事件

Posted by Elizabeth Huang on Sat, Jul 12, 2025

前言

玩過月曆相關功能的人應該都遇過類似的需求,重複事件,例如週會、每月公休日等,有沒有通用的格式可以表示呢?其實有的,而且很多月曆相關的套件,例如 full calendar,都支援這格式

日曆數據交換標準 iCalendar(RFC 5545)中的 3.3.10. Recurrence Rule

它可以用特定格式的字串表示這些重複事件,例如:

 1每個禮拜日
 2RRULE:FREQ=MONTHLY;BYDAY=SU
 3
 4每個月第 1 3 5 個禮拜五
 5RRULE:FREQ=MONTHLY;BYDAY=1FR,3FR,5FR
 6
 7每兩個禮拜六
 8RRULE:INTERVAL=2;FREQ=WEEKLY;BYDAY=SA
 9
10每週六日共 10 次
11RRULE:FREQ=MONTHLY;BYDAY=SA,SU;COUNT=10
12
13每年 12/24 23:58:40(也就是畢律斯鐘樓平安夜敲鐘的日子!)
14FREQ=YEARLY;BYMONTHDAY=24;BYHOUR=23;BYMINUTE=58;BYSECOND=40

另外需要設定開始時間 DTSTART,有三種表示方法

  1. DTSTART:19970714T133000 本地時間
  2. DTSTART:19970714T173000Z UTC 時間
  3. DTSTART;TZID=America/New_York:19970714T133000 指定時區

除此之外也可以設定結束時間 UNTIL,格式跟 DTSTART 一樣

更多例子可以看 章節 3.8.5.3

RRULE for PHP

許多語言都有好用的 RRule 套件可以使用,本文要介紹的是 php-rrule

假設我開一間早餐店,每週一休息,那我的公休日可以設為

1$rrule = new RRule([
2    'FREQ' => 'weekly',
3    'BYDAY' => 'MO',
4]);
5echo $rrule; // FREQ=WEEKLY;BYDAY=MO

現在我想取得未來 5 個休息日

1$occurrences = $rrule->getOccurrences(5);
2foreach ($occurrences as $occurrence) {
3    // 每個 $occurrence 都是 DateTime 物件
4    echo $occurrence->format('r'), "\n";
5}

output:

1Mon, 07 Jul 2025 10:18:36 +0000
2Mon, 14 Jul 2025 10:18:36 +0000
3Mon, 21 Jul 2025 10:18:36 +0000
4Mon, 28 Jul 2025 10:18:36 +0000
5Mon, 04 Aug 2025 10:18:36 +0000

設定 DTSTART

指定 DTSTART,如果沒指定,會以現在時間代替

1$rrule = new RRule([
2    'FREQ' => 'weekly',
3    'BYDAY' => 'MO',
4    'DTSTART' => '20250801T090000',
5]);

加上指定日期

如果我有一天要去看表演,需要臨時休息呢? 我們可以使用 RSet class,包含了 RRule、指定日期 RDATE、排除規則 EXRULE 和排除日期 EXDATE,然後用前述的方式取得休息日

RDATE 表示方式如下

1指定日期,8/11, 12, 15
2RDATE;VALUE=DATE:20250811,20250812,20250815

接下來建立 RSet,並加入 RRuleRDate

 1$rset = new RSet();
 2$rset->addRRule([
 3    'FREQ' => 'weekly',
 4    'BYDAY' => 'MO',
 5    'DTSTART' => '20250801T090000',
 6]);
 7$rset->addDate('20250811T090000');
 8$rset->addDate('20250812T090000');
 9$rset->addDate('20250815T090000');
10$occurrences = $rset->getOccurrences(5);
11foreach ($occurrences as $occurrence) {
12    echo $occurrence->format('r'), "\n";
13}

output:

1Mon, 04 Aug 2025 09:00:00 +0000
2Mon, 11 Aug 2025 09:00:00 +0000
3Tue, 12 Aug 2025 09:00:00 +0000
4Fri, 15 Aug 2025 09:00:00 +0000
5Mon, 18 Aug 2025 09:00:00 +0000

可以看到 8/11 本來就是週一公休日,即使再加上了指定日期,取得的結果中 8/11 依然只有一筆,不會有重複

需要注意的是,每條 $occurrence 都是 DateTime 物件,所以如果 RRuleRDate 是不同時間,即使日期相同也會出現兩條項目

 1$rset = new RSet();
 2$rset->addRRule([
 3    'FREQ' => 'weekly',
 4    'BYDAY' => 'MO',
 5    'DTSTART' => '20250801T090000',
 6]);
 7$rset->addDate('20250811');
 8$occurrences = $rset->getOccurrences(5);
 9foreach ($occurrences as $occurrence) {
10    echo $occurrence->format('r'), "\n";
11}

output:

1Mon, 04 Aug 2025 09:00:00 +0000
2Mon, 11 Aug 2025 00:00:00 +0000 <- 8/11 00:00
3Mon, 11 Aug 2025 09:00:00 +0000 <- 8/11 09:00
4Mon, 18 Aug 2025 09:00:00 +0000
5Mon, 25 Aug 2025 09:00:00 +0000

排除重複項和指定日期

使用 addExRrule()addExDate() 增加排除規則,格式增加規則相同 假設我們週一和週二公休,8/8 父親節多休一天,但 8/11 多上一天班,並且九月公休日只有週一

 1$rset = new RSet();
 2$rset->addRRule([
 3    'FREQ' => 'weekly',
 4    'BYDAY' => 'MO,TU',
 5    'DTSTART' => '20250801',
 6]);
 7$rset->addDate('20250808');
 8$rset->addExDate('20250811');
 9$rset->addExRule([
10    'FREQ' => 'weekly',
11    'BYDAY' => 'TU',
12    'DTSTART' => '20250901',
13    'UNTIL' => '20250930',
14]);
15$occurrences = $rset->getOccurrences(15);
16foreach ($occurrences as $occurrence) {
17    echo $occurrence->format('r'), "\n";
18}

output:

 1Mon, 04 Aug 2025 00:00:00 +0000
 2Tue, 05 Aug 2025 00:00:00 +0000
 3Fri, 08 Aug 2025 00:00:00 +0000
 4Tue, 12 Aug 2025 00:00:00 +0000
 5Mon, 18 Aug 2025 00:00:00 +0000
 6Tue, 19 Aug 2025 00:00:00 +0000
 7Mon, 25 Aug 2025 00:00:00 +0000
 8Tue, 26 Aug 2025 00:00:00 +0000
 9Mon, 01 Sep 2025 00:00:00 +0000
10Mon, 08 Sep 2025 00:00:00 +0000
11Mon, 15 Sep 2025 00:00:00 +0000
12Mon, 22 Sep 2025 00:00:00 +0000
13Mon, 29 Sep 2025 00:00:00 +0000
14Mon, 06 Oct 2025 00:00:00 +0000
15Tue, 07 Oct 2025 00:00:00 +0000

其他功能

檢查某日期是否在 RSet 內

1$rset->occursAt(new \DateTime('20250808')); // true

取得特定日期以後的事件

1$occurrences = $rset->getOccurrencesAfter(new \DateTime('20251001'), true, 5);

Bonus

取得 2026/02/16 開始未來 365 天內的第一個開工日

 1function getOpenDate(RSet $rset, \DateTime $start): \DateTime|null
 2{
 3    $end = (clone($start))->modify('+366 days');
 4    $period = new \DatePeriod($start, new \DateInterval('P1D'), $end);
 5    foreach (iterator_to_array($period) as $date) {
 6        if (!$rset->occursAt($date)) {
 7            return $date;
 8        }
 9    }
10    return null;
11}
12
13$firstOpenDate = getOpenDate($rset, new \DateTime('20260216'));
14if (is_null($firstOpenDate)) {
15    echo '找不到開工日';
16}
17echo $firstOpenDate->format('r'), "\n";

參考