Slide 1

Slide 1 text

PHPだって関数型したい 〜できること、できないこと〜 2026年07月11日 関数型まつり 2026 関根 純 @jsoizo

Slide 2

Slide 2 text

自己紹介 関根 純 せきね じゅん 経歴など いくつかの事業会社で開発に従事したのち、 現在は株式会社コドモンで働いています。 KotlinやScala, Apache Pekkoが好き。 PHPは12年ぶり2回目。 愛知県出身、都内在住。ディズニーとサッカーが趣味 X, GitHub: @jsoizo 2

Slide 3

Slide 3 text

PHPは関数型プログラミング(FP)向けの言語ではありません。 かつ、柔軟さが特徴で喜ばれて利用されている言語でもあります、 一方で古いバージョンに向けて書かれたソースコードと向き合う中で、 保守性の観点から苦しむこともあります。 モダンな(※)PHPの言語機能や静的解析を活かしていくことで、 歴史的経緯による曖昧さと向き合いつつ、堅牢なコードを目指すこともできます。 この場で紹介することを一つでも試していただけると嬉しいです。 ※ PHP 8.1(2021年リリース)以降を指します。PHPに詳しい人には今更かもしれません 🙇 はじめに・ゴール 3

Slide 4

Slide 4 text

今日話すこと なぜPHPで関数型?? 1 PHPの言語機能でできること 2 静的解析PHPStanの支援 3 それでもできないこと・使い所 4 4

Slide 5

Slide 5 text

今日話すこと なぜPHPで関数型?? 1 PHPの言語機能でできること 2 静的解析PHPStanの支援 3 それでもできないこと・使い所 4 5

Slide 6

Slide 6 text

失敗・null・状態のような曖昧さは、どのアプリケーションにもある。 しかし、 PHPでは古い標準APIやコーディングの慣習、array の万能さ等が 重なることで、曖昧さが型に現れないまま広がりやすい。 ⚠️ array : list 兼 record 兼 dictionary のフリーダムな器。      古いコードは「とりあえずarrayに入れておく」としがち 歴史あるPHPコードで起きやすい曖昧さ 6

Slide 7

Slide 7 text

DBの行は列の集まりなので、1レコードを array で受け取るのは自然。 ここは PHP の柔軟さが素直に活きていて、何も問題はない。 例: DBから1行を取ってくる // DB の1 行を array で受け取る $stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?'); $stmt->execute([$id]); $row = $stmt->fetch(PDO::FETCH_ASSOC); // 該当行があれば連想配列、無ければ fetch() は false (PDO の仕様) // $row = ['id'=>.., 'name'=>.., 'email'=>.., 'status'=>..] 7

Slide 8

Slide 8 text

古いコードでは、異常系をとりあえず false で返すことがある。 その array を業務ロジックで扱うと function findUser($pdo, $id): array|false { /* ... 前ページと同じように $row を fetch ... */ if (!$row) return false; // null も false if ($row['status'] === 'deleted') return false; // 削除済みも false return [ 'id' => $row['id'], 'name' => $row['name'], 'email' => $row['email'], // ただの string 'status' => $row['status'], // DB 由来の文字列がそのまま流れる ]; } 8

Slide 9

Slide 9 text

呼び出し側 $user = findUser($pdo, $id); if (!$user) { // 「null 」も「削除済み」も、同じ false に落ちる // 違う意味なのに、どちらだったのかは呼ぶ側から分からない return; } $user['foo'] = 'bar'; // array なので自由に書き換えられる // $user のキーや型は、呼ぶ側からは見えない echo $user['name']; 9

Slide 10

Slide 10 text

FPなら値や型で語れる情報が、PHPでは複数の慣習に分散し、シグネチャか ら消える。 本来語りたいこと 先の例 散らばりがちな表現 失敗した return false false / 例外 / エラー配列 存在しない return false null / false / [] 取りうる状態 $row['status'] === 'deleted' マジック文字列 / 定数 データの形 return [...] array / mixed 曖昧さの正体は「意図がわからなくなること」 10

Slide 11

Slide 11 text

ここでは、次のFPの道具をPHPに持ち込んでみる。 FPの道具で「曖昧さの局所改善」を狙う イミュータビリティ ● 直和型 ないしは タグ付きユニオン ● First class functionと関数合成 ● パターンマッチング ● Result型とRailway Oriented Programming ● 11

Slide 12

Slide 12 text

今日話すこと なぜPHPで関数型?? 1 PHPの言語機能でできること 2 静的解析PHPStanの支援 3 それでもできないこと・使い所 4 12

Slide 13

Slide 13 text

readonly を付けたプロパティは、初期化後の再代入を禁止する。 PHP 8.1 からフィールドに対して、 PHP 8.2 からはクラス全体を readonly にできるようになった。 → 不変な値オブジェクトを素直に表現できる 値を変えたいときは書き換えず、新しいインスタンスを作る というイミュータビリティを強制させる設計を持ち込める。 イミュータビリティ (PHP 8.1~) 13

Slide 14

Slide 14 text

イミュータビリティ (PHP 8.1~) final readonly class User { public function __construct( public string $id, public string $name, ) {} } $user = new User('u1', 'Alice'); // array なら書き換えられたが、readonly では弾かれる $user->name = 'Bob'; // Error: Cannot modify readonly property User::$name 14

Slide 15

Slide 15 text

取りうる状態を enum で列挙すれば、単純な直和型に近い表現になる。 match 式は値を返し、厳密比較( === )で分岐するので、状態ごとの処理 を一つの式にまとめて書ける。 ただし、 直和型とパターンマッチング ≒ enum + match式 コンパイルする言語ではないので、実行前に網羅性をチェックして実装漏 れを検知することはできない ● 各caseはデータを持てず、値を持つ代数的データ型は表現できない ● Scalaでいう sealed + data class|object のようなこと ○ 15

Slide 16

Slide 16 text

直和型とパターンマッチング ≒ enum + match式 enum UserStatus: string { case Active = 'active'; case Invited = 'invited'; case Deleted = 'deleted'; } // DB から来た文字列は、境界で enum 値に変換する $status = UserStatus::from('deleted'); $canLogin = match ($status) { UserStatus::Active => true, UserStatus::Invited => false, UserStatus::Deleted => false, }; 16

Slide 17

Slide 17 text

クロージャ、アロー関数、first-class callable がそろっており、 小さな関数を渡して組み合わせられるので、後の map / flatMap による合 成の土台になる。 First class functionをもつ // アロー関数, $withTax はCallable 型の値となる $rate = 1.1; $withTax = fn(int $price): float => $price * $rate; // 関数を値として渡せる $prices = array_map($withTax, [100, 200]); // [110.0, 220.0] // first-class callable: 既存の関数・メソッドも値にできる $labels = array_map(strtoupper(...), ['xs', 'm', 'l']); 17

Slide 18

Slide 18 text

パイプ演算子 |> は左の値を右の関数へ渡すことができる。 入れ子だった呼び出しを、左から右に読める形へ並べられる。 first-class callable と相性がよく、複数引数の関数はアロー関数で包む。 パイプライン (PHP 8.5~): 左から右に読む合成 // これまで: 内側から外側へ読む $slug = str_replace(' ', '-', strtolower(trim($input))); // PHP 8.5: |> なら左から右に読める $slug = $input |> trim(...) // 先頭・末尾の空白を取り除いて |> strtolower(...) // 小文字に変換して |> (fn($s) => str_replace(' ', '-', $s)); // スペースをハイフンにする 18

Slide 19

Slide 19 text

複数引数の関数は、カリー化して「残りの引数を待つ関数」にする。 できた部品を |> で並べれば、処理の流れがそのまま新しい関数になる。 パイプ演算子とカリー化を組み合わせる $map = fn($f) => fn($xs) => array_map($f, $xs); $filter = fn($f) => fn($xs) => array_values(array_filter($xs, $f)); // カリー化した部品を組み合わせて「税込合計を求める関数」をつくる $totalOf = fn(array $orders) => $orders |> $filter(fn($o) => $o->paid) // 支払い済みだけ残して |> $map(fn($o) => $o->price * 1.1) // 税込み価格へ変換して |> array_sum(...); // 合計する $total = $totalOf($orders); 19

Slide 20

Slide 20 text

Result型のようなFPの道具立てを型に乗せるには、 抽象度を上げて表現するためにジェネリクスが言語機能として求められる。 2026年には Bound-Erased Generic Types の RFC が提案されたが、投票で否決された。 reifiedにするというアプローチなども存在するが、 型推論の難しさや既存コードとの互換性などの観点から、当面は言語機能と しては入らなさそう。 言語の外側 = PHPDoc と静的解析でなんとか頑張る のが現状。 ジェネリクスや型の表現は言語の外に託す 20

Slide 21

Slide 21 text

今日話すこと なぜPHPで関数型?? 1 PHPの言語機能でできること 2 静的解析PHPStanの支援 3 それでもできないこと・使い所 4 21

Slide 22

Slide 22 text

静的解析 = PHPStan で得るもの PHPStanはPHPで実装されたPHPコードの静的解析ツール ● 言語機能だけでは表現できない型情報をPHPDocに書き、PHPStanに コードを解析させて整合性をチェックするのがプラクティス ● PHPDocが型宣言のように機能し、静的型付けに近い開発体験となる ● 10段階のレベルがあり、レベルをあげるごとに曖昧さを許容しなくな り具体性を強要するようになる ● 22

Slide 23

Slide 23 text

レベル 求める厳格さ・チェックすること Level 0〜2 PHP の動的さに対して、まず明らかなミスを拾う Level 3〜5 戻り値・プロパティ・引数の型整合性を見る Level 6 型注釈不足を問題にし始める。PHPDoc generics の出番 Level 7〜8 union / nullable の扱いが厳しくなり、type narrowing の価値が出る Level 9〜10 mixed を自由に扱えず、外部入力や array を具体的な型へ絞り込む設計が必要になる PHPStanの適用度合い 23

Slide 24

Slide 24 text

PHPの特徴である array は list 兼 record 兼 dictionary のフリーダムな器。 array{...} で record を、 list で同質なコレクションを表現する。 array の意味を絞る: array shape と list // Before: 何が入っているか型からは分からない /** @param array $row */ function toUser(array $row): User { /* ... */ } // After: キーと型が出る / 「User だけのリスト」も表せる /** @param array{id: string, name: string} $row */ function toUser(array $row): User { /* ... */ } /** @return list */ function activeUsers(): array { /* ... */ } 24

Slide 25

Slide 25 text

ジェネリクスは @template で表現でき、PHPStanが T / E を追跡する。 生の入力(mixed)を受け、検証を通った値だけを型のある値にする。 次頁は拙作の php-result を用いたスマートコンストラクタの例。 PHPDocジェネリクスで Result を作る 25

Slide 26

Slide 26 text

PHPDocジェネリクスで Result を作る final readonly class EmailAddress // データ型はreadonly でイミュータブルに { private function __construct(public string $value) {} // コンストラクタを隠匿 /** @return Result */ public static function parse(mixed $raw): Result // スマートコンストラクタ { return is_string($raw) && str_contains($raw, '@') ? Result::success(new self($raw)) : Result::failure(new InvalidEmail($raw)); } } // エラーも意味のある型で表す final readonly class InvalidEmail { public function __construct(public mixed $raw) {} 26

Slide 27

Slide 27 text

map は成功値だけを変換し、 flatMap は失敗しうる処理をつなぐ。 どこかで失敗したら以降は進まず、最初の失敗が返る。 例外や null に散っていた失敗処理が一本の線になり認知負荷を下げられる。 途中で増えた失敗の型も、PHPStanがunionとして追跡してくれる。 map / flatMap で「成功時だけ次に進む」を表現する /** @return Result */ function register(string $rawEmail): Result { return EmailAddress::parse($rawEmail) // 失敗しうる ->map(fn(EmailAddress $email) => new User($email)) // 成功時だけ変換 ->flatMap(fn(User $user) => $user->verify()); // 別の失敗が増える } 27

Slide 28

Slide 28 text

先の説明で match 式では「実行前に網羅性は確かめられない」としたが、 enum を渡せば、ケースの書き漏れを PHPStan が指摘してくれる。 enum + match の網羅性チェック // UserStatus はActive, Invited, Deleted をもつenum である function label(UserStatus $status): string { return match ($status) { UserStatus::Active => ' 利用中', UserStatus::Invited => ' 招待中', // Deleted を書き忘れると… // PHPStan: Match expression does not handle UserStatus::Deleted }; } 28

Slide 29

Slide 29 text

PHPの enum の各 case はデータを持てないが、 @phpstan-sealed というアノテーションで値を持つ直和型を表現可能。 継承可能なクラスを限定することができる。が… 直和型の表現 : @phpstan-sealedアノテーション /** @phpstan-sealed EmailContact|PhoneContact */ interface Contact {} final readonly class EmailContact implements Contact { public function __construct(public EmailAddress $email) {} } final readonly class PhoneContact implements Contact { public function __construct(public string $number) {} } // Error: Type FaxContact is not allowed to be a subtype of Contact. final readonly class FaxContact implements Contact {} 29

Slide 30

Slide 30 text

enum と違い、sealed では match 式での網羅性が保証されない。 全パターンを書いても「default挙動不足」と指摘されてしまう。 このあたりがどうしても片手落ち感があり残念なところ。 @phpstan-sealedの欠点: match では default / ignore が要る function notify(Contact $c): string { /** @phpstan-ignore match.unhandled (Contact is sealed: EmailContact|PhoneContact) return match (true) { $c instanceof EmailContact => "mail: {$c->email->value}", $c instanceof PhoneContact => "sms: {$c->number}", // 全パターンを書いても PHPStan は網羅と認めず default を要求 // ignore するか、絶対に通らないdefault 挙動を書くしかない }; } 30

Slide 31

Slide 31 text

php-result は Success / Failure の sealed として実装した。 そのうえで「match式に登場したときに Success と Failure の両方が登 場しているか」をチェックするPHPStanのカスタムルールを提供している。 php-resultの実装でやっていること // Error: Match expression on Result type is not exhaustive. Missing: Failure. match (true) { $result instanceof Success => 'success', }; match (true) { // すべて網羅していればPHPStan が通過する $result instanceof Success => 'success', $result instanceof Failure => 'failure', }; 31

Slide 32

Slide 32 text

flatMap の連鎖は、前の結果を後の処理でも使うときにネストが深くなる。 php-resultの binding はGeneratorを使って、 Scalaのfor内包表記のように平らに書けるようにしたもの。 binding: ネストする合成を平らに書く findOrder($orderId) ->flatMap(fn($order) => findCustomer($order->customerId) ->flatMap(fn($customer) => createInvoice($order, $customer))); // binding: yield で成功値を取り出し、Success が返る限り次へ進む $result = Result::binding(function () use ($orderId) { $order = yield findOrder($orderId); $customer = yield findCustomer($order->customerId); $invoice = yield createInvoice($order, $customer); 32

Slide 33

Slide 33 text

Generator の型の制約(次頁)から binding では yield の結果が mixed になってしまう。 ゆえに @var を手書きして型推論を補うしかない。 ただし、yield を通ると型が追えなくなる $result = Result::binding(function () use ($orderId) { /** @var Order $order */ // ← 書かないと $order は mixed として推論される $order = yield findOrder($orderId); /** @var Customer $customer */ // ← yield の結果に型がつかない $customer = yield findCustomer($order->customerId); /** @var Invoice $invoice */ // ← yield のたびに手書きが要る $invoice = yield createInvoice($order, $customer); return $invoice; }); 33

Slide 34

Slide 34 text

yield で処理を一時停止して呼び出し側に値を渡し、 send() で外から値を受け取って続きから再開できる関数。 Generator全体でsendできるのは TSend なので、 複数の値をsendするとmixedに潰れてしまう… Generator function gen(): Generator { $x = yield 1; // 1 を渡して一時停止。再開時に送られた値が $x に入る echo $x + 1; // 11 } $g = gen(); $g->current(); // 1 $g->send(10); // 10 を送って再開 34

Slide 35

Slide 35 text

今日話すこと なぜPHPで関数型?? 1 PHPの言語機能でできること 2 静的解析PHPStanの支援 3 それでもできないこと・使い所 4 35

Slide 36

Slide 36 text

ここまで見てきた個別の型は、PHPStanで作ることができた。 Result も、sealedによる直和型も、網羅性のチェックも作れた。 このあたりはどうしてもできない。 PHPStanで表現するのが難しいこと Higher Kinded Types : 型を横断して一般化 ● Type Class : 振る舞いの後付け ● 36

Slide 37

Slide 37 text

全体を1つの Result / Option にまとめる traverse が欲しいとして、 「型引数を取る型」を型引数にできれば、実装は1回で済むのだが… F<_> のように型パラメータを宣言することができない /** * @template F<_> ← こう書きたいが、書けない * @template TIn * @template TOut * @param list $items * @param callable(TIn): F $f * @return F> */ function traverse(array $items, callable $f) {} traverse($ids, findUser(...)); // Result, E> traverse($rows, parseEmail(...)); // Option> 37

Slide 38

Slide 38 text

PHPStanの @template が受け取れるのは具体的な型だけ。 同じ形の traverse を、型ごとに書くことになる。 現実: 同じ traverse を型ごとに書く /** @return Result, TErr> */ function traverseResult(array $items, callable $f): Result { /* ... */ } /** @return Option> */ function traverseOption(array $items, callable $f): Option { /* ... */ } // 中身はほぼコピペ 38

Slide 39

Slide 39 text

Type Classも同様に表現できない。 Scalaの given と using のような、 PHP言語として後付する機構がなく、PHPStanも静的解析に過ぎない。 Type Class: 既存の型に振る舞いを後付けしたい /** * @template T of Comparable // Comparable は自作interface とする * @param non-empty-list $items * @return T */ function maxOf(array $items): mixed { /* ... */ } // DateTimeImmutable にComparable な性質を後付できるとこのようにできたら理想的であるができない maxOf([new DateTimeImmutable('today'), new DateTimeImmutable('tomorrow')]); 39

Slide 40

Slide 40 text

Comparable を要求する関数に、組み込みクラスへ直接実装させられない。 wrapした自前の型を作って、それを渡すことになる。 現実: wrapした自前の型で実装 /** @implements Comparable */ final readonly class Timestamp implements Comparable { public function __construct(public DateTimeImmutable $value) {} // 引数は mixed だが、型は PHPStan が Comparable として追う public function compareTo(mixed $other): int { return $this->value <=> $other->value; // 委譲するだけ } } maxOf([ new Timestamp(new DateTimeImmutable('today')), 40

Slide 41

Slide 41 text

Result やinterfaceの実装など、個別の型は作れる。 が、それらを統一的に扱う一段上の抽象は作れない。 作れるもの 表現できないもの 実務での割り切り HKT 個別の Result / Option 任意の F<_> という抽象 型ごとに書く Type Class interfaceの実装 既存型への後付け wrapして自前の型にする ※ Kotlinも同様の制約があり、Arrow-ktのようなライブラリは同じような割り切りをしている 型の抽象度をあげることができない 41

Slide 42

Slide 42 text

どのくらいPHPにFP要素を いれるのが良いだろうか? 42

Slide 43

Slide 43 text

アプリ全体をFPに書き換える必要はない。 曖昧さが実際に痛い場所へ、前提の少ない順に取り入れる。 1. 言語機能だけ — 値を不変にする(依存ゼロ) 2. PHPStanを足す — 意図を型に書く(ツール導入) 3. 境界を決める — 曖昧さを閉じ込める(設計判断) 全部はFPにしない: 小さく始める 43

Slide 44

Slide 44 text

新しく書くクラスに readonly を付け、setterを書かない。 依存ゼロなので、めのまえの新規コードから始められる。 スマートコンストラクタを採用してもいい。 まず、言語機能だけで不変にする final readonly class User { public function __construct(public string $id, public string $name) {} // 変更は、書き換えずに新しいインスタンスで返す public function withName(string $name): self { return new self($this->id, $name); } } 44

Slide 45

Slide 45 text

mixed を減らし、 array には array{...} / list で形を書く。 CIに入れて、レベルを少しずつ上げる。生成AIで一気に対応しても良い。 型が追える範囲が広がるほど、Result や網羅性チェックなど、 FPの道具が効く範囲もそのまま広がっていく。 次に、PHPStanで意図を型に書く 45

Slide 46

Slide 46 text

外から来る array / mixed / 例外は、境界で型の付いた値に変換する。 たとえば、Functional Core / Imperative Shellパターン imperative shell — Controller / Eloquent / HTTP 境界 — EmailAddress::parse(...) / Result::catch(...) functional core — 判断・計算・分岐 Result・直和型・不変・合成 — 今日の道具が効く場所 境界で変換してしまえば、Functional Core 内では曖昧さがなくなる。 最後に、境界で曖昧さを閉じる 46

Slide 47

Slide 47 text

境界を越えるときに変換すれば、Core内は型の付いた値だけになる。 Result にするのは、ドメイン上の失敗と境界を越える失敗だけでよい。 すべての例外を Result にする必要はない。 境界でやることは「変換」だけ // 外から来た mixed を、境界で値オブジェクトに変換する $email = EmailAddress::parse($request->input('email')); // → Result // 外部I/O の例外を、境界で Result に変換する $user = Result::catch(fn() => $repo->find($id)); // → Result 47

Slide 48

Slide 48 text

全部やる必要はないく、曖昧さが実際に痛い場所から1つだけでも。 明日から取り入れるなら readonly な値オブジェクトを使う ● enum + match で分岐を明示する ● mixed を減らす ● array を array shape や list に寄せる ● ドメイン上の失敗だけ Result にする ● 境界で例外を Result に変換する ● 48

Slide 49

Slide 49 text

まとめ: 曖昧さを局所的に削るための FP モダンPHPなら、言語機能だけでも FPのテクニックをある程度使える ● 言語で表現しきれない型は、 PHPStanで設計意図として明示する ● すべてを型で完全に保証するのは諦め、 曖昧さが問題になりうる場所の局所的改善が現実的 ● 49

Slide 50

Slide 50 text

No content