こんにちは!PR TIMES の河瀨翔吾(@shogogg)です。エンジニアリングマネージャーとして、プレスリリース配信サービス PR TIMES の開発や開発チームのマネジメント、業務改善、採用などを行っています。好きな春の味覚はたらの芽です。
この記事では、先日開催された PHPerKaigi 2026 でお話させていただいたトークの内容について解説をしたいと思います。
PHPerKaigi 2026 について
PHPerKaigi 2026 は2026年3月20日(金)から22日(日)の3日間、中野セントラルパークカンファレンスにて開催された技術カンファレンスです。PHP が大好きな PHPer(ぺちぱー)を中心とした開発者が集まり、PHP に関するトークはもちろんのこと、フロントエンドや設計全般に関する話題など、様々な内容のセッションが行われました。

トーク内容
今回は『我々はなぜ「層」を分けるのか 〜「関心の分離」と「抽象化」で手に入れる変更に強いシンプルな設計〜』というタイトルで20分のトークを行いました。プロポーザルの内容については下記をご覧ください。

スライドは下記で公開中ですが、スライドだけでは伝わりにくい部分が多いため、当日のトーク内容をベースに解説します。
「層」についてのおさらい
いろいろな「層」の分け方
この記事を読むみなさんは、色々なやり方で「層」を分けながら開発をしていると思います。世の中には色々な「層」の分け方があり、古くは MVC や3層アーキテクチャ、そしてここ10年ぐらいはクリーンアーキテクチャ的な考え方で分けることが増えてきました。
いにしえの PHP
ここに「いにしえの PHP」があります。
<?php
$conn = mysql_connect('localhost', 'root', '');
mysql_select_db('shop', $conn);
$id = mysql_escape_string($_GET['id']);
$sql = "SELECT * FROM items WHERE id = {$id}";
$result = mysql_query($sql);
echo '<ul>';
while ($row = mysql_fetch_assoc($result)) {
echo "<li>{$row['name']} - ¥{$row['price']}</li>";
}
echo '</ul>';
?>スライド用に用意したため、かなり簡易的なものではありますが、PHP 4の時代、2000年代前半の Web 界隈にはこのように「クエリパラメータを取得して DB を直接呼び出し、そのまま HTML を出力する」といったワイルドなコードがそこら中に存在していました。
MVC の流行

そして MVC が流行します。MVC とはこの図のように処理を行う Model と画面表示を担う View を分離し、その間を取り持つ Controller を配置することで、ロジックとプレゼンテーションの分離を実現するものでした。
The Clean Architecture

これはみなさんおなじみ「クリーンアーキテクチャの同心円」です。
フロントエンドの進化に伴い、バックエンドが直接 HTML をレンダリングすることは減り、PHP バックエンドが複雑なプレゼンテーションを担当することはあまりなくなりました。一方で、ニーズや技術的背景はどんどん複雑化しています。
我々はロジックをより細かい「層」に分けることで、その複雑性に立ち向かっている訳です。
「層」が実現する関心の分離と抽象化
では「層」を分けることで具体的に何がどううれしいのでしょうか。ここで、少したとえ話をします。

彼の名前は Mr. Logic。凄腕のビジネスマンです。

彼のビジネスは好調で、多くの顧客から色々なチャネルで連絡があります。電話、FAX、郵送、メール、チャット……その全てに彼が一人で対応しています。顧客ごとにコミュニケーション方法を変える必要があり、とても大変です。

そこでアシスタントを雇用します。アシスタントは顧客からの連絡をすべてチャットに集約してくれます。顧客側の都合に関係なく、自分にとって一番使いやすい「チャット」だけ見ればよくなったことで、Mr. Logic の作業効率は大幅に向上します。

さて、Mr. Logic が実際に仕事をするためには、様々なデータを扱う必要があります。
そのデータは複数のクラウドサービスに分散しており、形式も CSV や JSON などバラバラ。時には SQL を書いて抽出・分析する必要があります。さらに、組織の都合でクラウドサービスを乗り換えることもあり、その度にやり方を変える必要があります。
ここで重要なのは、それらの「お作法」が彼にとっては本質的な仕事ではないこと。余計な手間なのです。

そこで、IT システムの専門家を雇用します。
専門家は、クラウドシステムに精通しているので、彼が細かく指示しなくても、必要なデータを必要なところから集め、まとめてくれます。さらに、Mr. Logic が扱いやすいよう、使い慣れているスプレッドシート形式で共有してくれることで、彼は組織やクラウドサービスの都合に振り回されず、効率よく仕事ができるようになりました。

こうして、Mr. Logic は外の世界の複雑さから解放され、ビジネスの本質に集中できるようになりました。

ほとんどの方がお気づきかと思いますが、これはアプリケーションの「層」そのものの例えです。
コントローラー層はクライアントとのやり取りを担当し、インフラ層はデータベースや外部 API、時には外部パッケージやレガシーモジュールとのやり取りを行います。中心にあるユースケースやサービスと呼ばれる層は、そういった複雑な「外部の都合」を一切気にすることなく、ビジネス的なロジックに専念する。
これが「関心の分離」です。

そして関心の分離を実現するためのテクニックとして「抽象化」があります。Mr. Logic ことサービス層を作るとき、我々はつい「ユーザーからJSONで受け取ったデータをDBに保存する」といった具体的な振る舞いを意識してしまいます。

しかし、それは間違いです。「データがどこから、どんな形で来たか」はコントローラー層が既に解決していますし「データをどこに、どう保存するか」はインフラ層が考えるべきことです。

よって、サービス層で考えるべきことはここまで抽象化できます。こうやって正しく「抽象化」することで「関心の分離」がぐっと近づきます。
今回は説明のために単純なケースを例に挙げていますが、実際のアプリケーションではもっと複雑なビジネスロジックを扱うことが多々あります。そこに「入力の複雑さ」や「ミドルウェアや外部システムの複雑さ」まで持ち込んでしまうと、ロジックが必要以上に複雑化してしまいます。
正しく「抽象化」して「関心の分離」を実現することでひとつひとつをシンプルに保ち、変更に強くする。そのために我々は「層」を分けているのです。
「層」を形骸化するアンチパターン
さて、せっかくキレイに層を分けても、その目的をきちんと理解して実装しないと「層」は簡単に形骸化し、意味を失ってしまいます。
そこで具体的なコード例を交えながらそんな「アンチパターン」をいくつか紹介します。
① HTTPの都合に振り回されるサービス
最初のアンチパターンは、サービス層のコードが HTTP の都合に振り回されている例です。個人的な感覚ではありますが、程度の差こそあれ、実際の現場でちょくちょく見掛ける気がしています。
// Controller
public function index(Request $request)
{
// Controller がリクエストされた入力値を Service に丸投げしている 😱
return $this->service->findUsers($request->context(), $request->all());
}
// Service
public function findUsers(Context $context, array $params)
{
// ロジック内で HTTP の都合(文字列)の対応が必要に 😱
$isActive = isset($params['is_active']) && $params['is_active'] === 'true';
$role = UserRole::tryFrom((int)$params['role']);
$dateFrom = isset($params['date_from']) ? Carbon::parse($params['date_from']) : null;
// 本来やりたいことはこれだけのはず……😢
$tenantId = $context->isAdmin()
? ($params['tenant_id'] ?? null)
: $context->tenantId();
return $this->userRepository->search($tenantId, $role, $dateFrom, $isActive);
}問題のあるコードをご用意しました。Controller は、本来「サービスが扱いやすい、サービスが期待する値」を渡すべきですが、HTTP で受け取った値をそのまま丸ごと Service に渡しています。そのため、本来「HTTPの都合」であるはずの変換や判定、例外処理などを Service 側で行う必要があります。
今回はスライド用に用意したため、短いコードになっていますが、実際の業務で用いられるような複雑なロジックでこのように実装されてしまうとサービスが複雑化し、テストも大変です。
// Controller
public function index(FindUsersRequest $request)
{
// Controller が Service の期待する形に入力値を変換して渡している 😊
return $this->service->findUsers(
$request->context(),
$request->toCriteria(),
);
}
// Service
public function findUsers(Context $context, UserSearchCriteria $criteria)
{
// Service 層のコードは本来の責務に集中できるように 😊
$tenantId = $context->isAdmin()
? $criteria->targetTenantId
: $context->tenantId();
return $this->userRepository->search($tenantId, $criteria);
}こちらが問題を解消したバージョンです。Controller が入力値を Service の期待する形式に変換してから渡すようになっています。これにより、Service 層のコードはシンプルになり、変更する理由も「ユーザー検索におけるビジネス要件の変更」だけになりました。つまり単一責任原則(SRP)に従った設計になっています。
これならばテストも容易ですし、仮にこのサービスが API ではなく、バッチやキュー経由で実行されるジョブに変更されても Service 層の変更は不要です。
② 浸食する Active Record
2つ目のアンチパターンとして、Active Record の濫用による弊害をご紹介します。
Active Record パターンは非常に強力なデザインパターンです。Ruby on Rails の流行に伴って普及し、PHP においては Laravel の Eloquent が代表例です。
さて、会員などのデータに社内で参照するためのメモ欄・備考欄を用意するのはよくあることだと思います。そういった要件が後から追加され、次のようにデータベースにカラムを追加したとします。
// DB Migration(Laravel)
// 社内用のメモ欄を追加するため、専用のカラムをテーブルに追加 👍
Schema::table('members', function (Blueprint $table): void {
// 社内管理用メモ
$table->string('internal_note');
});ユーザー向けの API では会員データを Eloquent を使って取得し、そのまま JSON 形式で返しているとします。今回は管理者向けの変更だったのでユーザー向け API の実装は変更しませんでした。
// ユーザー向け API 用のサービス
// 管理機能の変更だったのでユーザー向け API は変更しなかった……🤔
public function show(int $id): ?Member {
return Member::find($id);
}さて、特に何も対策をしていない場合、このユーザー向け API のレスポンスはどうなるでしょうか。
// API のレスポンス
// 追加したカラムの内容がユーザー向け API に漏洩してしまった!😱
{
"id": 1,
"name": "John Smith",
"internal_note": "クレーマーなので要注意"
}ご覧の通り、ユーザー向け API のレスポンスに社内管理用のメモが流出してしまいました。上記のようなメモがご本人に見えてしまったら、どんな結果になるかは……想像したくありませんね。
Active Record は強力なデザインパターンであり、小規模なアプリケーションであれば開発スピードに大きく貢献します。一方で、データベースに密結合していることによって、データベース側の変更がアプリケーションに影響を与えてしまいます。そのため、データ表現としてそのまま利用していると今回の例のような思わぬトラブルに繋がる場合があります。
Laravel + Eloquent では $hidden プロパティを使う方法など、いくつかの対処方法があります。しかし、状況によって最適なソリューションがそれぞれ異なるため、ここでの紹介は割愛します。
個人的な意見としては、ある程度の規模になったら(またはそれを見込むのであれば)Active Record はインフラ層に閉じ込め、サービス層では独自に定義したモデル(DTO/POPO)に変換して扱うのがオススメです。
③ 技術的詳細を漏らす例外
3つ目のアンチパターンは、例外に関するお話です。今回も Laravel + Eloquent を例に用います。
// Repository
public function lookup(int $id): ?Member
{
// Eloquent をインフラ層に閉じ込める、一見堅牢なコードに見えるが
// もしここでクエリの実行に失敗すると QueryException が throw される 🤔
return EloquentMember::find($id)?->toDomainModel();
}
// Service
public function lookup(int $id): ?Member
{
// ここで例外を catch していないので技術的詳細が漏れてしまう 😱
return $this->repository->lookup($id);
}Eloquent モデルを独自の Member クラスに変換しており、一見データベースに関する技術的詳細をリポジトリに閉じ込めている、しっかりしたコードに見えます。
しかし、それは正常系に限った話です。クエリの実行に失敗してしまうと QueryException が throw されてしまい、インフラ層に閉じ込めたつもりの技術的詳細が漏れてしまいます。
例外の扱いについてはトーク後の質問や、Ask the Speaker でいくつか質問をいただいたので補足しておきます。
Laravel におけるExceptionHandlerのような仕組みで例外をキャッチし、対応する場合は今回のコードでもまったく問題ないと考えていますが、アプリケーションコードのどこかの層でQueryExceptionを catch するようなコードがあるとしたら、それは今回のアンチパターンに該当し、改善の余地があると思います。
今回のトーク、および本記事においては後者を想定して解説しています。
// Repository
public function lookup(int $id): ?Member
{
return EloquentMember::find($id)?->toDomainModel();
}
// Service
public function lookup(int $id): ?Member
{
// 例外をキャッチする堅牢なコードに見えるが……🤔
try {
$member = $this->repository->lookup($id);
} catch (QueryException $e) {
// Service 層のコードに `QueryException` という技術的詳細が!😱
$this->logger->error('Database query failed', ['exception' => $e]);
}
return $member;
}何らかの理由で、インフラ層における例外をサービス層で catch したい場合を考えます。上記のサンプルコードでは簡略化のためログ出力を例に挙げていますが、実際にはリカバリ処理、リトライ処理などが考えられます。
今回のケースでは、せっかく層に分けて技術的詳細を隠蔽したはずが「リポジトリが Eloquent 経由でクエリを発行している」ことを知らないとサービス層が実装できない状態になっています。
// Custom exception for better error handling
final class RepositoryException extends \RuntimeException
{
}
// Repository
public function lookup(int $id): ?Member
{
// Eloquent で発生した例外を独自例外で wrap してから throw する👍
try {
return EloquentMember::find($id)?->toDomainModel();
} catch (QueryException $e) {
throw new RepositoryException('Failed to lookup the member from database', 0, $e);
}
}
// Service
public function lookup(int $id): ?Member
{
// Service 層では技術的詳細を気にすることなく例外処理が書けるようになった!👍
try {
$member = $this->repository->lookup($id);
} catch (RepositoryException $e) {
$this->logger->error('An error occurred while looking up the member', ['exception' => $e]);
}
return $member;
}上記が改善版のコードです。独自例外を定義し、インフラ層ではこの独自例外で例外を wrap することで、Service 層から QueryException という技術的詳細が消え、関心の分離が実現されました。
まとめ
ロジックの複雑さに立ち向かう
- バックエンドが複雑なプレゼンテーションを担うことは減った一方、扱う技術やドメインの複雑さは増加傾向にある
- 「層」を分離することで、我々は複雑さに立ち向かっている
「層」によって関心を分離する
- ビジネスロジックを技術的な詳細から解放することでシンプルになり、変更に強くなる
- 内側の層(サービス)の都合に外側の層(コントローラーやインフラ)が合わせるのがポイント
適切な「抽象化」が分離の鍵
- 抽象化が甘すぎると、Service 層に技術的な詳細が浸食しやすくなる
- 関心の分離は、適切な抽象化によって達成される
We are Hiring!!
PR TIMES ではカンファレンスジャンキーな PHPer はもちろん、様々なポジションで積極採用中です!まずは気軽にカジュアル面談してみませんか?詳しくは、下記採用サイトをご覧ください!



