PAYATHON 2026

使用 PHP Carbon 正确设置下个月付款日期

支付阿杰

结论

不要用上一次实际到期日的“日”作为下一期的基准。应该单独保存约定付款日,例如 31。计算时先进入目标月份,再按下面的方式设置日期:

min($billingDay, $targetMonth->daysInMonth)

这样,从 1 月 31 日开始,后续日期会依次为 2 月 28 日、3 月 31 日和 4 月 30 日,不会因为 2 月被截断,之后就一直沿用每月 28 日。

推荐写法

use Carbon\Carbon;

function nextPaymentDate(Carbon $currentDueDate, int $billingDay): Carbon
{
    $targetMonth = $currentDueDate
        ->copy()
        ->startOfMonth()
        ->addMonth();

    return $targetMonth->day(
        min($billingDay, $targetMonth->daysInMonth)
    );
}

$billingDay = 31;

$januaryDue = Carbon::create(2018, 1, 31, 0, 0, 0);
$februaryDue = nextPaymentDate($januaryDue, $billingDay);
$marchDue = nextPaymentDate($februaryDue, $billingDay);

echo $februaryDue->toDateString(); // 2018-02-28
echo $marchDue->toDateString();    // 2018-03-31

这里的 $billingDay 始终保留原始约定日 31。2018-02-28 只是因为当月没有 31 日而得到的实际付款日期,并不表示付款规则已经改成每月 28 日。

为什么直接调用 day(31) 会溢出

下面这段代码先得到 2018-02-28,随后又尝试把 2 月的日期设为 31:

Carbon::create(2018, 1, 31)
    ->addMonthsNoOverflow(1)
    ->day(31);

2018 年 2 月没有 31 日,底层日期运算会将超出的 3 天滚动到 3 月,最终得到 2018-03-03。

addMonthsNoOverflow() 只保证“增加月份”这一步不会溢出,之后调用 day(31) 时仍然不会自动截断日期。

使用 Carbon 的无溢出单位设置

部分 Carbon 版本提供了通用的 setUnitNoOverflow(),可以将日期限制在指定月份内:

use Carbon\Carbon;

$billingDay = 31;

$currentDueDate = Carbon::create(2018, 1, 31, 0, 0, 0);

$nextDueDate = $currentDueDate
    ->copy()
    ->startOfMonth()
    ->addMonth()
    ->setUnitNoOverflow('day', $billingDay, 'month');

echo $nextDueDate->toDateString(); // 2018-02-28

计算下一期时,继续使用同一个 $billingDay:

$followingDueDate = $nextDueDate
    ->copy()
    ->startOfMonth()
    ->addMonth()
    ->setUnitNoOverflow('day', $billingDay, 'month');

echo $followingDueDate->toDateString(); // 2018-03-31

能否使用 setUnitNoOverflow(),取决于项目安装的 Carbon 版本。如果当前版本没有这个 API,使用 min($billingDay, $targetMonth->daysInMonth) 更直观,也不依赖 setDayNoOverflow(31) 之类的专用方法。

注意事项

Carbon 对象默认通常是可变的,所以示例中调用了 copy(),以免意外修改原日期。也可以改用 CarbonImmutable:

use Carbon\CarbonImmutable;

$currentDueDate = CarbonImmutable::create(2018, 1, 31);

业务数据中还应分别保存“约定付款日”和“本期实际到期日”。如果只保留截断后的 2018-02-28,就无法判断原来的规则究竟是每月 28 日,还是每月月底。

备注:内容仅供参考。