ViewModelって何?なんで必要?
Bladeを書いているときに、
「ここでnumber_formatするの、なんか気持ち悪いな…」
って思うことありませんか?
最初のうちはぜんぜん気にならなかったんですけど、
ページが増えてくると、
- 同じ整形を何回も書いてる
- ちょっとした文言がコントローラに出てくる
- nullチェックがちらばる
みたいな、地味だけどモヤモヤする状況が増えてきて。
調べていくうちに、「あ、これってViewModelで解決できるのか」と腑に落ちました。
目次
ViewModelってなんだろう
ざっくり言うと、
ViewModelは「画面で使う形」にデータを整えるクラス。
DTOが「運ぶための箱」なら、
ViewModelは「見せるための形」にする人。
Bladeに渡す前に、
- テキストの合成
- ラベル名の変換
- フォーマット(日付、価格、状態など)
といった表示の最終整形を全部ここに集めてつかうようですね。
なんで必要なのか
最初は「Bladeでformatすればいいじゃん」と思ってたんですよ。
でもプロジェクトが大きくなるにつれて、
だんだん「どこで整形してるか分からない」問題が出てきました。
同じような表示ロジックがControllerにも、Resourceにも、Bladeにも点在してて、
修正するときにあちこち探す羽目になる。
それで「表示用の責務をまとめる場所」が欲しくなった。
そこにぴったりハマったのがViewModelでした。
自分が理解した、DTOとの違い
| 種類 | 役割 | ロジック |
|---|---|---|
| DTO | 層と層のあいだでデータを運ぶ | 整形しない(生データ) |
| ViewModel | Viewで使いやすい形に整える | 表示整形(フォーマット・ラベルなど) |
DTOは生データ、ViewModelは見せ方。
この2つを分けると、コードの責務が一気に見えやすくなりました。
たとえばこんな感じ
final readonly class SpotListItemViewModel
{
public function __construct(
public int $id,
public string $title,
public string $priceRangeLabel,
public string $locationLabel,
public string $publishedLabel,
) {}
public static function fromRow(SpotListItemRow $r): self
{
$date = DateFormatter::ymdWithJpWeekday(CarbonImmutable::parse($r->createdAtIso));
$loc = $r->prefectureName . $r->cityName;
$price = self::formatPrice($r->hasPrice, $r->monthlyPriceMin, $r->monthlyPriceMax);
return new self(
id: $r->id,
title: $r->name,
priceRangeLabel: $price,
locationLabel: $loc,
publishedLabel: $date,
);
}
private static function formatPrice(bool $has, int $min, int $max): string
{
if (!$has) return '—';
if ($min > 0 && $max > 0) return number_format($min).'〜'.number_format($max).'円';
if ($min > 0) return number_format($min).'円〜';
if ($max > 0) return '〜'.number_format($max).'円';
return '—';
}
}
ViewModelは、
DTO(=生データ)を受け取って、
Bladeで使いやすい形に変えるクラスです。
フォーマット処理をここに閉じ込めておくと、
Bladeがとても静かになります。
Bladeは「出すだけ」でいい
@foreach ($vm->items as $it)
<article class="c-card">
<h3>{{ $it->title }}</h3>
<p>{{ $it->locationLabel }} / {{ $it->publishedLabel }} / {{ $it->priceRangeLabel }}</p>
</article>
@endforeach数値整形も日付もここでは一切しません。
VMがもう「表示用の最終形」なので、
Bladeは値を出すだけ。
この状態になると、Bladeが一気に読みやすくなるし、
デザイナーや他の人が見ても壊しにくくなります。
ViewModelに入れていいもの・入れないもの
いろいろ調べた中で、ここは線を引いておくとラクだなと思いました。
入れていい
- 文字列の合成(都道府県+市区名など)
- 状態の変換(0→「非公開」など)
- ラベル化(null→「—」、false→「未設定」など)
- 単純なフォーマット(日付、価格など)
入れない
- データ取得(DBアクセス)
- 業務ロジック(料金計算や判定)
- 外部サービス呼び出し
VMは“見せ方”の責任だけを持つ。
「どう出すか」まではやるけど、「何を出すか」はやらない。
この線を引いておくと、迷いが減ります。
名前の付け方で迷った話
最初、「SpotListItem」って名前のままDTOとVMを両方置いてたんですが、
どっちがどっちか分からなくなったので、
最終的にこう落ち着きました。
DTO: SpotListItemRow
Result: SpotListResult
VM: SpotListItemViewModel命名で責務を分けておくと、
IDEでファイルを一覧しただけで
「この層で何をしてるか」がわかって地味に便利です。
変換ポイントは1か所だけにする
最初のうちは「DTO→VMの変換」を
Controllerとかで直接書いてたんですが、
それもだんだん散らかる。
今はViewModelの中に静的ファクトリを1つだけ作って、
そこを唯一の入口にしています。
$vm = SpotListItemViewModel::fromRow($dto);変換ポイントが固定されるだけで、
責務の境界がはっきりしてテストもしやすくなりました。
「ViewModelがあると気持ちが落ち着く」ってこういうことかも
使ってみて思ったのは、
ViewModelって“便利なクラス”というより
整形ロジックをしまっておく安全な棚みたいな存在なんですよね。
Bladeがすっきりして、
Controllerが軽くなって、
結果的に「どの層で何をしてるか」が明確になる。
それだけなんですけど、地味に開発体験が変わります。
自分の運用ルール(現時点)
ViewModel::fromRow()でDTOから生成する- nullはできるだけ使わず、「—」や既定値で表示する
- Bladeでは整形しない
- 複数データを扱うときは
〇〇ViewModelと〇〇ListViewModelに分ける - 共通整形は
Formatterクラスへ
まだ完璧じゃないけど、このルールで混乱はだいぶ減りました。
まとめ:ViewModelは「見た目の責務を閉じる場所」
調べていくうちにわかったのは、
ViewModelはMVCの「V」ではなく、
「Viewをシンプルに保つための中間層」ってこと。
BladeにあったロジックをViewModelに逃がすだけで、
コードがぐっと整理される。
言葉で聞くと小さな変化だけど、
体感では「設計の手触り」がかなり変わります。
今回は以上です!