DTOとViewModelについて考えてみた
Laravelで開発していると、「どこで整形すべきか」がわからなくなることがよくあります。
Eloquentのまま渡すのか、DTOを挟むのか、それともViewModelまで作るのか。
自分のプロジェクトでもまさにそこが曖昧で、
- DTOにformatメソッドが増えていく
- BladeでCarbonやnumber_formatを直書き
- nullチェックがあちこちに散る
…という状態になっていました。
最初に結論を言うと、「責務を分ける」ことを最優先にしたら、すべてが整理されたんです。
nullをどう扱うかは、そのあと自然に整っていきました。一旦リセットしました。
目次
きっかけ:DTOが太ってきた
はじめは「EloquentモデルをそのままViewに渡すのはよくない」と思って、
DTOを作り始めました。
でも使っているうちに、DTOにどんどん整形処理が増えていきます。
日付や価格、住所の合成、ラベルの変換……。
そのうち、DTOがほぼViewModel化してしまい、
「これ何の層なんだっけ?」と混乱するようになりました。
DTOが責務を越えていたんです。
決めたこと:変換ポイントを1か所に。流れは一方向
そこでルールを一度リセットしました。
いろんな層の整形をひとまとめにして、
変換ポイントを1か所に決めることにしました。
まず責務の分離だけにフォーカスしてルールを決めました。
Infrastructure(DB/外部I/F)
→ DTO(*Row/*Result) // 整形しない、生データの運搬役
→ ViewModel::fromRow/Result() // 表示用に整形する唯一の入口
→ Blade // 値を出すだけ- DTO:生データを安全に運ぶ箱。整形・依存は持たない。
- ViewModel:画面に最適化された最終形。見た目の整形はすべてここで完結。
- Blade:単なる出力。フォーマット処理は禁止。
この構成にしてから、レビューやテストで「どこを見ればいいか」が一気に明確になりました。
“どこで整形するか”の迷いが消えたんです。
null非許容は「あとから効いてくる」ルールだった
この分離を運用しているうちに、
「nullを許すとまた曖昧になる」と感じるようになりました。
なので、DTO側でnullを使わない(欠損は既定値+フラグで表す)運用ルールを追加。
すると今度はViewModelの分岐がさらに薄くなり、
テストも安定して動くようになりました。
つまり順番としては、この流れ。
① 責務を分ける → ② nullを減らす
nullをなくすのは前提じゃなくて、設計を補強する仕上げの一手という位置づけです。
実装例(簡略)
DTO(整形しない・構造固定)
final readonly class SpotListItemRow {
public function __construct(
public int $id,
public string $name,
public int $monthlyPriceMin, // 欠損時は0
public int $monthlyPriceMax, // 同上
public bool $hasPrice, // 価格情報が有効かどうか
public string $prefectureName, // 欠損時は空文字
public string $cityName,
public string $createdAtIso, // ISO8601文字列
) {}
}ViewModel(表示用の最終形)
final readonly class SpotListItemViewModel {
public function __construct(
public int $id,
public string $title,
public string $priceRangeLabel, // "120,000〜150,000円" / "—"
public string $locationLabel, // "東京都"
public string $publishedLabel, // "2025/10/21(火)"
) {}
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 '—';
}
}Formatter(状態を持たない道具箱)
final class DateFormatter {
public static function ymdWithJpWeekday(CarbonImmutable $dt): string {
$w = ['日','月','火','水','木','金','土'][$dt->dayOfWeek];
return $dt->format('Y/m/d')."($w)";
}
}Blade(表示するだけ)
@foreach ($vm->items as $it)
<article class="c-card">
<h3>{{ $it->title }}</h3>
<p>{{ $it->locationLabel }} / {{ $it->publishedLabel }} / {{ $it->priceRangeLabel }}</p>
</article>
@endforeachnullを使わないメリット
null禁止にすると、コードの見通しが劇的に変わりました。
if ($dto->name !== null)がなくなり、VMが読みやすいViewModel::fromRow()が「必ず成立する」のでテストが軽いhasXxxフラグがあることで意図が明確
「nullを排除する」こと自体が目的じゃなく、
結果的に責務分離を強く支えるルールになった感覚です。
チェックリスト(自分へのメモ)
- DTOに整形ロジックを書いていないか?
- 変換は ViewModelの静的ファクトリだけか?
- Bladeに
Carbonやnumber_formatが出てないか? - nullがDTO内に紛れ込んでないか?(既定値+フラグで済む?)
- VMが外部依存(DBやサービス呼び出し)をしていないか?
まとめ
今回の整理で学んだのは、
責務を分けてシステムの見通しを良くするために
「DTOとViewModelを分ける」こと自体が目的であり、
null非許容はその設計を支える運用ルールにすぎないということ。
先に分ける。
そして、余白が見えてきたら null を整える。
順番を間違えなければ、Laravelの設計はずっとシンプルで、
テストも壊れにくくなりますね。
今回は以上です!