Laravelで期間計算を値オブジェクトに集約!保守性と可読性を高める設計手法

Laravel で業務システムを作っていると、「年度」「四半期」「月次」などの期間計算があちこちに散らばりがちです。

  • 集計処理の中に年度判定が書かれている
  • コントローラで「4月1日〜翌年3月31日」を毎回計算している
  • 月次レポートのロジックがサービス層とバッチで微妙に違う

こうした “日付ロジックの分散” は、後から仕様変更が入ったときに必ず足を引っ張ります。

  • 「年度の開始月が変わった」
  • 「四半期の区切りを変えたい」
  • 「月末の扱いを統一したい」

これらを 全ファイルを grep して修正するのは、誰にとっても幸せではありません。

期間ロジックは「値オブジェクト」に閉じ込める

そこでおすすめなのが、期間を表すクラスを作り、すべての計算をそこに集約するという設計です。

Laravel では Carbon が強力なので、値オブジェクトとの相性も抜群です。

例えば「年度」を扱うなら、次のようなクラスを用意します。

<?php

namespace App\Support\TargetPeriod;

use Carbon\Carbon;

/**
 * 年度(4月1日開始・翌年3月31日終了)を表します。
 */
class FiscalYear implements TargetPeriod
{
    private int $year;

    private array $quarters = [];

    public function __construct(Carbon $date)
    {
        $this->year = $date->month >= 4 ? $date->year : $date->year - 1;
    }

    public function __toString(): string
    {
        return $this->value();
    }

    public function startDate(): Carbon
    {
        return Carbon::create($this->year, 4, 1, 0, 0, 0);
    }

    public function endDate(): Carbon
    {
        return Carbon::create($this->year + 1, 3, 31, 23, 59, 59);
    }

    public function value(): string
    {
        return (string) $this->year;
    }

    public function label(): string
    {
        return $this->year . '年度';
    }

    public function quarters(): array
    {
        if (empty($this->quarters)) {
            for ($i = 0; $i < 4; $i++) {
                $this->quarters[] = new TargetQuarter($this->startDate()->addMonths($i * 3));
            }
        }

        return $this->quarters;
    }
}

こうしておくと何が嬉しいのか

1. 年度ロジックが一箇所に集約される

年度の開始月を 4 月 → 1 月に変えたい場合でも、修正はこのクラスだけ。

2. サービス層・バッチ・ビューで同じ計算結果を共有できる

「年度の文字列表現」「開始日」「終了日」「四半期」など、すべて統一される。

3. テストが書きやすい

値オブジェクトは入力と出力が明確なので、ユニットテストが非常に簡単。

4. コードの意図が読みやすくなる

値オブジェクトとして期間を表現しておくと、Eloquent のスコープにそのまま渡せるというメリットがあります。

例えば、注文モデルに「指定された期間の注文だけを絞り込む」スコープを定義するとこうなります。

use Illuminate\Database\Eloquent\Builder;
use App\Support\TargetPeriod\TargetPeriod;

class Order extends Model
{
    #[Scope]
    protected function ofTargetPeriod(Builder $query, TargetPeriod $targetPeriod): void
    {
        $query->whereBetween('ordered_at', [
            $targetPeriod->startDate(),
            $targetPeriod->endDate(),
        ]);
    }
}

これで、コントローラやサービス層では次のように直感的に書けます。

$fy = new FiscalYear(now());

$orders = Order::ofTargetPeriod($fy)->get();

「年度の注文を取得している」という意図がコードから読み取れるので、ビジネスロジックの意味がそのままコードに現れるのが大きな利点です。

5. 集計期間も簡単に階層化できる

年度 → 四半期 → 月次 のように階層的な集計を行う場合、
期間ロジックを値オブジェクトに閉じ込めておくと、Blade 側の集計処理も自然な形で書けます。

例えば、次のようなレポートを作るとします。

  • 月次集計
  • 四半期集計
  • 年度集計

期間クラスがしっかりしていれば、Blade は次のように書けます。

@foreach ($fy->quarters() as $quarter)
    @foreach ($quarter->months() as $month)
        {{-- 月次集計 --}}
    @endforeach
    {{-- 四半期集計 --}}
@endforeach
{{-- 年度集計 --}}

このコードは、
「年度 → 四半期 → 月次」というビジネス構造をそのまま表現している
という点が非常に重要です。

まとめ

日付計算や期間の表現は、業務システムの中でも特に “散らばりやすい” ロジックです。
しかし、値オブジェクトとして一箇所に集約するだけで、拡張性・可読性・保守性が劇的に向上します。

さらに、TargetPeriod インターフェイスを用意しておけば、年度以外にも以下のような期間クラスを追加できます。

  • TargetMonth(月次)
  • TargetQuarter(四半期)
  • Last30Days(直近30日)
  • CustomPeriod(任意の開始日・終了日)

どれを渡しても同じように動くように、期間ロジックを抽象化できるのもポイントです。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です