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

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(任意の開始日・終了日)

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

LaravelのArtisanコマンドでコンソール出力はどうすべきか

Laravel

Artisanコマンドの処理をサービスクラスに委譲したとき、「どこでコンソール出力すべきか」は実装方針によって3つのパターンに分かれます。結論としては、サービスクラスはロジックに集中させ、出力はコマンド側で行うのが最も保守性が高いです。ただし例外的にサービス側で出力してもよいケースもあります。

🎯 結論:基本は「Artisanコマンド側で出力」

サービスクラスは「ビジネスロジック」に専念させ、コンソール出力(I/O)はコマンドクラスが担当するのがクリーンアーキテクチャ的にも Laravel 的にも自然です。

理由は3つあります。

  • サービスは CLI 以外(Web、Queue)からも呼ばれる可能性がある
  • I/O を含めるとテストがしづらくなる
  • 責務が混ざると再利用性が落ちる

🧩 3つの実装パターンと使い分け

1) コマンド側で出力(推奨)

サービスは結果だけ返し、コマンドが info()error() を呼ぶ。

// app/Console/Commands/ImportUsers.php
public function handle(UserImportService $service)
{
    $result = $service->run();

    $this->info("Imported {$result->count} users.");
}

// app/Services/UserImportService.php
public function run(): ImportResult
{
    // ロジックだけ
    return new ImportResult($count);
}

メリット

  • 責務が明確
  • テストしやすい
  • Web/Queue からも使える

2) サービスに「出力用のインターフェース」を渡す(柔軟)

サービスに $output を渡し、必要なら出力できるようにする。

public function handle(UserImportService $service)
{
    $service->run($this->output);
}

public function run(OutputStyle $output)
{
    $output->writeln('Start importing...');
}

メリット

  • サービス側で進捗を出したいときに便利
  • テスト時はモックできる

デメリット

  • サービスが CLI 依存になる(ただしインターフェースなので軽減)

3) サービス側で Log に書き、コマンド側で表示(間接方式)

サービスはログだけ残し、コマンドが必要に応じて表示。

// Service
Log::info('Import started');

// Command
$this->info('Import started');

メリット

  • サービスは I/O を持たない
  • ログとコンソール出力を分離できる

デメリット

  • 出力の同期が面倒

🧠 非常に重要なポイント

Laravel のサービスクラスは CLI 以外からも呼ばれる前提で設計するのが長期的に強いです。

  • Web コントローラ
  • Queue ジョブ
  • スケジューラ
  • テストコード

これらから呼ばれる可能性があるため、サービスにコンソール出力を埋め込むと再利用性が落ちるのが最大のデメリットです。

👍 実務でのおすすめ構成

責務を分けて長期的に保守しやすい構成を重視するなら、パターン①(コマンド側で出力)が一番おすすめです。

  • サービスはロジックだけ
  • コマンドは I/O だけ
  • テストも書きやすい
  • 後から Queue 化・API 化しやすい

ひとこと

サービス側で進捗を出したいケース(大量データ処理など)があるなら、パターン②の「OutputStyle を渡す」方式が最も柔軟です。

Laravelで複数選択可能なチェックボックスを実装する

Laravel

Laravelで複数選択可能なチェックボックスを実装するためのベストプラクティスをご紹介します。これを読めば、もうチェックボックスの実装に悩む必要はありません。

モデル構造

例えば、User モデルに以下のようなリレーションを定義してみましょう。ユーザーには「作成者(Author)」と「編集者(Editor)」といった複数の役割を割り当てることができます。

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

class User extends Model
{
    /**
     * ユーザーに属するロール
     */
    public function roles(): BelongsToMany
    {
        return $this->belongsToMany(Role::class);
    }
}

ルート定義

routes/web.phpファイルに以下のルートを定義してあるとしましょう。

use App\Http\Controllers\UserController;

Route::get('/users/{user}', [UserController::class, 'edit'])->name('user.edit');
Route::put('/users/{user}', [UserController::class, 'update'])->name('user.update');

GETのルートはユーザーの役割を編集するフォームを表示し、PUTルートで変更内容をデータベースへ保存します。

コントローラ実装

次に、これらのルートへの受信リクエストを処理するコントローラを見てみましょう。editメソッドではデータベースからすべての役割を取得してビューへ渡しています。

<?php

namespace App\Http\Controllers;

use App\Models\User;
use App\Models\Role;
use Illuminate\Http\Request;

class UserController extends Controller
{
    /**
     * ユーザー編集画面の表示
     */
    public function edit(User $user)
    {
        $roles = Role::all();

        return view('user.edit', ['user' => $user, 'roles' => $roles]);
    }

    /**
     * ユーザー情報の更新
     */
    public function update(Request $request, User $user)
    {
        $request->validate(['roles' => 'array|exists:roles,id']);

        $user->roles()->sync($request->roles);

        return redirect()->route('user.edit', ['user' => $user]);
    }
}

チェックボックス表示

ビューでは渡された役割データをループ処理してチェックボックスを表示できます。今のところ、checked属性は設定していません。

<!-- /resources/views/user/edit.blade.php -->

<form action="{{ route('user.update', ['user' => $user]) }}" method="POST">
    @csrf
    @method('PUT')

    @foreach ($roles as $role)
        <div class="form-check">

            <input
                id="role{{ $role->id }}"
                type="checkbox"
                name="roles[]"
                @class(['form-check-input', 'is-invalid' => $errors->has('roles')])
                value="{{ $role->id }}"
            >

            <label for="role{{ $role->id }}" class="form-check-label">
                {{ $role->name }}
            </label>

            @if ($loop->last && $errors->has('roles'))
                <div class="invalid-feedback">{{ $errors->first('roles') }}</div>
            @endif

        </div>
    @endforeach

    <button class="btn btn-primary">保存</button>
</form>

チェック済み属性

それでは、checked属性を設定してみましょう。チェック済みの判定結果を、ビューへ渡すデータに付け足すようにします。

/**
 * ユーザー編集画面の表示
 */
public function edit(Request $request, User $user)
{
    $roles = Role::all()->keyBy('id')->toArray();
    foreach (array_keys($roles) as $role_id) {
        $roles[$role_id]['checked'] = false;
    }
    foreach ($request->old('roles', $user->roles->modelKeys()) as $role_id) {
        if (isset($roles[$role_id]['checked'])) {
            $roles[$role_id]['checked'] = true;
        }
    }
    return view('user.edit', ['user' => $user, 'roles' => $roles]);
}

これで、ビューでは渡されたデータのフラグを確認するだけでcheckedの判別ができるようになりました。

<!-- /resources/views/user/edit.blade.php -->

<form action="{{ route('user.update', ['user' => $user]) }}" method="POST">
    @csrf
    @method('PUT')

    @foreach ($roles as $role)
        <div class="form-check">

            <input
                id="role{{ $role['id'] }}"
                type="checkbox"
                name="roles[]"
                @class(['form-check-input', 'is-invalid' => $errors->has('roles')])
                value="{{ $role['id'] }}"
                @checked($role['checked'])
            >

            <label for="role{{ $role['id'] }}" class="form-check-label">
                {{ $role['name'] }}
            </label>

            @if ($loop->last && $errors->has('roles'))
                <div class="invalid-feedback">{{ $errors->first('roles') }}</div>
            @endif

        </div>
    @endforeach

    <button class="btn btn-primary">保存</button>
</form>

めでたし、めでたし☺

Laravelでのルーティングからビューにデータを渡すまで

Laravel

Laravelにおけるルーティングからビューにデータを渡すまでの基本的な実装パターンをまとめました。状況に応じて適切なパターンを使い分けましょう。

ルーティング

クロージャによるルーティングはいたって単純です。

Route::get('/', function () {
    return view('welcome');
});

ルーティングにクロージャを使用すると、ルートキャッシュは動作しません。ルートキャッシュを使用するには、コントローラルートを定義します。

Route::get('/user', 'UserController@index');

ビュールート

ルートからビューを返すだけの場合は、Route::viewメソッドを使用します。このメソッドを使用しても、ルートキャッシュは動作するようです。

Route::view('/welcome', 'welcome');

ビューにデータを渡す

viewヘルパ関数を使用して、データを配列でビューに渡せます。

Route::get('/', function () {
    return view('greeting', ['name' => 'James']);
});

withメソッドでビューに渡すデータを個別に追加することもできます。

Route::get('/', function () {
    return view('greeting')->with('name', 'Victoria');
});

ビュールートでデータを渡す

Route::viewメソッドでは、ビューへ渡すデータの配列を第3引数として指定します。

Route::view('/welcome', 'welcome', ['name' => 'Taylor']);

ここで、allgetのようなメソッドでモデルを取得して渡すのは避けるべきです。モデルはクロージャやコントローラの中で取得しないと、該当ルート以外のリクエストでも、routes/web.phpファイルが読み込まれる毎にクエリが実行されてしまいます。また、場合によってはユニットテストでIlluminate\Database\QueryException例外が投げられることがあります。

Route::view('/', 'welcome', ['categories' => Category::all()]);
1) Tests\Feature\ExampleTest::testBasicTest
Illuminate\Database\QueryException: SQLSTATE[HY000]: General error: 1 no such table: categories (SQL: select * from "categories")

これはテスト実行時、マイグレーションが実行される前に、routes/web.phpファイルが読み込まれることが原因です。

全ビュー間のデータ共有

アプリケーションの全ビューでデータを共有するには、View::shareメソッドを使います。

View::share('key', 'value');

通常、サービスプロバイダのbootメソッド内で呼び出しますが、ここでもモデルを取得して渡していると、先ほどの例と同様にエラーの原因になります。

参考
QueryException: General error: 1 no such table: {table_name} (SQL: select * from “{table_name}”) · Issue #27018 · laravel/framework

ビューコンポーザ

ビューコンポーザは、ビューがレンダーされる直前に呼び出されます。したがって、ビューにモデルを渡す場合にも使えます。

View::composer('welcome', function ($view) {
    $categories = Category::all();
    $view->with('categories', $categories);
});

ビューコンポーザを複数のビューに適用するには、View::composerメソッドの最初の引数を配列で渡します。

View::composer(['welcome', 'home'], function ($view) {
    //
});

composerメソッドに渡しているビュー名には、ワイルドカードとして*を使用することもできます。

View::composer('*', function ($view) {
    //
});

Laravelにおける存在チェックの書き方

Laravel

Laravelでビューに渡されたEloquentコレクションの中身が存在しているか判別するプログラムの書き方をまとめます。

データをビューへ渡す

まず、データを表示するためにEloquentでコレクションを取得してビューへ渡します。

public function index()
{
    $users = User::all();

    return view('users', ['users' => $users]);
}

単にデータを表示する

ビューに渡されたデータはループで表示できます。

<html>
    <body>
        <h1>User</h1>

        @foreach($users as $user)
            <p>{{ $user->name }}</p>
        @endforeach
    </body>
</html>

データの存在を判別する

データの存在を判別するには、isNotEmptyメソッドを使います。

<html>
    <body>
        <h1>User</h1>

        @if ($users->isNotEmpty())
            <ul>
                @foreach($users as $user)
                    <li>{{ $user->name }}</li>
                @endforeach
            </ul>
        @else
            Nothing Found
        @endif
    </body>
</html>

逆の動作である、isEmptyメソッドを使うこともできます。

<html>
    <body>
        <h1>User</h1>

        @if ($users->isEmpty())
            Nothing Found
        @else
            <ul>
                @foreach($users as $user)
                    <li>{{ $user->name }}</li>
                @endforeach
            </ul>
        @endif
    </body>
</html>

その他の判別方法

より単純なケースでは、@forelse()が使えるかもしれません。

<html>
    <body>
        <h1>User</h1>

        @forelse($users as $user)
            <p>{{ $user->name }}</p>
        @empty
            Nothing Found
        @endforelse
    </body>
</html>

ちなみに、以下のような書き方では@empty()がうまく判別できませんでした。

<html>
    <body>
        <h1>User</h1>

        @foreach($users as $user)
            <p>{{ $user->name }}</p>
        @endforeach
        @empty($users)
            Nothing Found
        @endempty
    </body>
</html>

リレーションの存在の判定

リレーションの有無を調べるには、existsメソッドとdoesntExistメソッドが使用できます。

<html>
    <body>
        <h1>User</h1>

        @if ($user->posts()->exists())
            The user has some posts
        @endif

        @if ($user->posts()->doesntExist())
            The user doesn't have any posts
        @endif
    </body>
</html>