Slide 1

Slide 1 text

Relayer というPHPのフレームワークを作った @polidog Shizuoka Tech #2 1

Slide 2

Slide 2 text

自己紹介 @polidog アプリケーション開発者 パーティーハード株式会社という開発 会社を経営しています。 清水市出身、神奈川県在住 5 歳と 1 歳の男の子のパパ Web 2

Slide 3

Slide 3 text

去年、React Server Componentsの話をしました https://speakerdeck.com/polidog/react-server-components 3

Slide 4

Slide 4 text

(Next.js)を推してたのに、なんで今年はPHPのフレ ームワーク? RSC 4

Slide 5

Slide 5 text

Next.js は好き。でも、しんどくなってきた パッケージが重い。node_modulesと依存更新に疲れた RSC はサーバーで動く。ブラウザ前提のパッケージは で壊れるし、常駐プロセスなのでモジュール内にstateを持つライブラリは メモリリークの温床になる PHP なら FrankenPHP でシングルバイナリ化までできる。デプロイはファイ ル1個 そして今のPHPは普通にモダン。型も静的解析も常駐ランタイムもある npm window is not defined だったら、あの開発体験をPHPで作ればいいのでは? 5

Slide 6

Slide 6 text

今こそ、Hypertext Preprocessorとしての PHPの価値を見 直すべきでは? PHP は最初から、ハイパーテキストを作るための言語だった 6

Slide 7

Slide 7 text

今のPHPフレームワークへの不満 Controller で値を集めて、viewに渡す。以上。 の構造は20年ずっと変わっていない Twig / Blade はしょせんテンプレートエンジンで、 JSX/TSX の表現力に勝て ない コンポーネント指向で書けないから、結局「SPA + API」構成に逃げがち MVC 7

Slide 8

Slide 8 text

コア技術のusePHP React ライクにPHPを書けるようにするライブラリ。まずはここから 8

Slide 9

Slide 9 text

React ライクにかけるPHP、usePHP 独自拡張子 .psx // src/Pages/page.psx return fn () => (

It works

{date('Y')}

); これは 年のページです。

でPHP式を埋め込み、PascalCaseタグはコンポーネント はReact流( に自動変換) コンパイルすると のPHPコードに変換される { ... } className class H::section(...) 9

Slide 10

Slide 10 text

コンパイルするとこうなる コンパイル前( ) // .psx return fn () => (

It works

{date('Y')}

); これは 年のページです。

コンパイル後 // return fn () => H::section(className: 'card', children: [ H::h1(children: 'It works'), H::p(children: [' ', date('Y'), ' ']), ]); これは 魔法じゃない。属性は名前付き引数、子要素は 年のページです。 children のただの関数呼び出し 10

Slide 11

Slide 11 text

コンポーネントは関数スタイルとクラススタイル 関数スタイル // — src/Components/Card.psx return fn (array $props) => (

{$props['title']}

{$props['children'] ?? null}
); クラススタイル コンストラクタで依存が自動注入される // — final class UserDetailPage extends PageComponent { public function __construct(private readonly UserRepository $users) {} public function render(): Element { return

{$this->users->find($this->getParam('id'))->name}

; } } 11

Slide 12

Slide 12 text

条件分岐もループも、ただのPHP式 Twig/Blade のような専用構文( {% if %} / @foreach 条件分岐 → 値を返す三項演算子で書く )はない {$loggedIn ? Logout : Login} ループ → 要素の配列を返す array_map で書く
    {array_map(fn ($x) =>
  • {$x['name']}
  • , $items)}
JSX と同じ流儀。 { } に書けるのは式だけ。新しいテンプレート構文を覚えなくていい 12

Slide 13

Slide 13 text

useState もある return fc(function () { [$count, $setCount] = useState(0); return (
Count: {$count} $setCount($count + 1)}>+
); }); でラップした関数コンポーネントの中でだけHooksが使える セッターは に紐づく。実体はサーバーへのリクエスト fc() onClick 見た目はReactのカウンター。でも状態はサーバー側にある 13

Slide 14

Slide 14 text

useEffect と状態の保存先 useEffect(function () use ($tab) { error_log("tab: {$tab}"); return fn () => error_log('cleanup'); }, [$tab]); // $tab が変わったときだけ実行 deps の意味はReactと同じ: は毎回、 は初回だけ、値は変更時 で現在URLや の動的セグメントも取れる useRouter() null [] [id] 状態の保存先 (既定) サーバーセッション。ページ遷移をまたいで残る リクエストごとにリセット 署名付きで HTML に埋め込む(サーバーはステートレス) StorageType Session Memory Snapshot 14

Slide 15

Slide 15 text

複雑なUIは本物のReactで書ける(React Islands) use Polidog\Relayer\React\Island; return fn () => ( {Island::mount('Chart', ['points' => $data])} ); クライアント側でコンポーネントを登録 // window.relayerIslands.register('Chart', (el, props) => { createRoot(el).render(); }); チャートやエディタなど、複雑なUIが必要な場所だけReactをマウントでき る。propsは にJSONで渡る data-react-props 15

Slide 16

Slide 16 text

このusePHPを核にしたフルスタックフレームワークが、 Relayer 16

Slide 17

Slide 17 text

Relayer Next.js App Router ク 風の規約で書けるPHPフルスタックフレームワー ルーティング・API・認証・キャッシュ・DBをひとつのbootエントリにまと める規約重視の設計 のディレクトリ構成がそのままURLになる( は動的セグメン ト) ビュー層にusePHPを使うので、すべてコンポーネントで書ける Server Actions みたいな機構がある src/Pages/ composer require polidog/relayer vendor/bin/relayer init php -S 127.0.0.1:8000 -t public [id] 17

Slide 18

Slide 18 text

Relayer の技術構成 ブラウザ — HTML + usephp.js(プログレッシブエンハンスメント)/ React Islands ▲▼ HTTP CDN — Defer + Cache-Control ▲▼ Relayer でキャッシュを最大化 本体(PHP) ルーティング Server Actions Validation src/Pages/ がそのまま URL に フォーム処理 + CSRF自動 Zodライクなスキーマ検証 usePHP — .psx を PHP コードにコンパイルするビュー層(← さっき説明したやつ) ▲▼ SQL Tehilim — スキーマファーストなDBツールキット(後半で解説) 18

Slide 19

Slide 19 text

ディレクトリがそのままURLになる src/Pages/ ├── layout.psx ├── page.psx ├── users/ │ ├── page.psx │ └── [id]/page.psx ├── (admin)/ └── _private/ 全ページ共通レイアウト # # / # /users # /users/42 # URL # に出ないグループ化 ルーティング対象外 は階層ごとに重なる。Next.jsのlayoutと同じ感覚 URL の衝突は初回リクエストではなく 時にエラーになる layout.psx routes:compile 19

Slide 20

Slide 20 text

API は route.php を置くだけ // src/Pages/api/users/route.php return [ 'GET' => fn (UserRepository $users): Response => Response::json(['users' => $users->all()]), 'POST' => function (Request $req, UserRepository $users): Response { $users->create($req->allPost()); return Response::json(['ok' => true], 201); }, ]; メソッドをキーにしたハンドラマップを返すだけ 引数の型を見て Request・サービス・PageContext が自動で注入される HTTP 20

Slide 21

Slide 21 text

Server Actions みたいな機構もある return function (PageContext $ctx, UserRepository $users): Closure { $save = $ctx->action('save', function (array $form) use ($users, $ctx): void { $users->create($form['name']); $ctx->redirect('/users'); // 303 See Other }); return fn () => ( save ); }; フォーム処理をクロージャで直接書く。APIエンドポイントは不要、CSRFも自動 21

Slide 22

Slide 22 text

Zod ライクなバリデーションが標準装備 use Polidog\Relayer\Validation\Validator; $schema = Validator::object([ 'email' => Validator::string()->trim()->email(), 'name' => Validator::string()->trim()->min(1, ' 'age' => Validator::int()->min(0)->optional(), ]); 名前は必須です。'), $result = $schema->safeParse($form); if (!$result->success) { $errors = $result->errors; // return; // } 同じページを再描画 フィールド単位のエラー 失敗したらそのまま再描画、成功したらPRG。Server Actionsと相性がいい 22

Slide 23

Slide 23 text

認証もビルトイン クラススタイル 属性でページ保護。 でロール必須も // — #[Auth(roles: ['admin'])] #[Auth] final class DashboardPage extends PageComponent { /* ... */ } 関数スタイル 。未認証ならリダイレクト // — requireAuth() return function (PageContext $ctx): Closure { $user = $ctx->requireAuth(); // requireAuth(['admin']) return fn () =>

{$user->displayName}

; }; ようこそ でロール必須 を実装してDIに登録すると でロ グインできる セッションログインもトークン認証(Firebase / Cognito)も、同じ仕組み で書ける 23 UserProvider $auth->attempt($email, $password)

Slide 24

Slide 24 text

Defer コンポーネント 「ログイン名だけ動的」なページ、CDNに乗せられますか? セッションを使うと Set-Cookie が付き、ページ丸ごとキャッシュ不可に return fc( fn (array $props) => , defer: new Defer(name: 'user-header', cacheControl: 'private, no-store'), ); ページには SSR 時は fallback だけ描画 → あとから ページ本体はCDNキャッシュ、この部分だけ } /> GET /_defer/user-header と置く で本体を取得 private, no-store 動的な部分だけを切り出せば、ページは丸ごとCDNに乗る 24

Slide 25

Slide 25 text

Defer はクライアント側でもキャッシュできる localStorage にキャッシュ(opt-in) #[Defer(name: 'feed', localCache: true, localCacheTtl: 60)] リロード・タブをまたいで保持される (秒)で有効期限を指定 — HTTPの キャッシュ破棄は のバンプ or localCacheTtl DEFER_CACHE_VERSION CDN Cache-Control とは独立 clearDeferCache() とブラウザの2層キャッシュで、動的部分の再取得も最小限にできる 25

Slide 26

Slide 26 text

細かいところもひととおり揃えてある キャッシュ — ページ単位で / ETag を宣言。 が一致すれば描画もDBアクセスもせず304 セッションは遅延起動 — 状態を触らないページは を吐かないの でCDNに乗る i18n — 依存ゼロの多言語化。ロケール解決は URL プレフィックス → セッシ ョン → Cookie → Accept-Language ミドルウェア / CORS / HTTPクライアント / ロガー ページ単位のJS読み込み — サービスはSymfony DIコンテナでオートワイヤ プロファイラ(dev環境限定)、CLIコマンド、.envカスケード HTTP Cache-Control If-None-Match Set-Cookie $ctx->js('/assets/chart.js', defer: true) 全部ドキュメントに書いてあります → https://relayer.polidog.jp/ 26

Slide 27

Slide 27 text

DB を支えるTehilim 27

Slide 28

Slide 28 text

スキーマファーストなDBツールキット、Tehilim 同じく自作の、Prisma風ワークフローのDBツールキット 方針は「データは連想配列、型はPHPDoc」。ORMのクラスマッピングはし ない 型安全はPHPStanに委ねる、静的解析前提の設計 composer require polidog/tehilim vendor/bin/tehilim init # vendor/bin/tehilim generate # vendor/bin/tehilim migrate dev --name init # スキーマから型付きクライアントを生成して使う スキーマ生成 型付きクライアント生成 マイグレーション実行 28

Slide 29

Slide 29 text

schema.tehilim にスキーマを書く model User { id Int @id @default(autoincrement()) email String @unique name String? posts Post[] } model Post { id Int @id @default(autoincrement()) title String published Boolean @default(false) authorId Int author User @relation(fields: [authorId], references: [id]) } ほぼPrismaのschema。 でnull許可、 ? @relation でリレーション 29

Slide 30

Slide 30 text

クエリもPrisma風 $found = $db->user->findUnique(['where' => ['email' => '[email protected]']]); $posts = $db->post->findMany([ 'where' => [ 'OR' => [ ['title' => ['contains' => 'PHP']], ['body' => ['contains' => 'PHP']], ], 'published' => true, ], 'orderBy' => ['createdAt' => 'desc'], 'take' => 20, ]); findUnique / findMany / insert Prisma 流 など、 where 演算子も select / include も 30

Slide 31

Slide 31 text

連想配列なのにPHPStanで型が見える $row = $db->user->findUnique([ 'where' => ['id' => 1], 'select' => ['email', 'name'], ]); // PHPStan : // array{email: string, name: string|null, id: int}|null にはこう見えている echo $row['email']; // OK echo $row['age']; // PHPStan がエラーにする 行データはプレーンな連想配列で、型情報は で供給する PHPStan 拡張が の内容から戻り値のarray shapeを絞り込む @phpstan-type select 31

Slide 32

Slide 32 text

Relayer とTehilimの連携 final class UsersPage extends PageComponent { public function __construct(private readonly TehilimClient $db) {} public function render(): Element { $users = $this->db->user->findMany([ 'include' => ['posts' => ['where' => ['published' => true]]], ]); return
    {array_map(fn($u) =>
  • {$u['name']}
  • , $users)}
; } } 型付きクライアントをDIで注入して使う。 でリレーションも取れる プロファイラはdev環境だけ注入するので、本番コードに計測機構が載らな32 い include

Slide 33

Slide 33 text

これからはAIと一緒に書く時代 Relayer はAI協働を前提に設計してある 規約は の1ファイルに集約。ファイル規約も各種契約も「やらな いこと」も全部ここ / は2行のポインタファイルで、どのAIツールから入って も同じ規約に辿り着く で にrelayer-routingスキルとrelayer-reviewerサブエ ージェントを生成する 生成コードはrelayer-reviewerが規約に沿っているかチェックする RELAYER.md AGENTS.md relayer init CLAUDE.md .claude/ 規約重視のフレームワークは、AIが迷わない 33

Slide 34

Slide 34 text

まとめ でも みたいなコンポーネントベースの開発体験が欲しくて、 を作った PHP RSC Relayer すべてコンポーネントで書けて、Server Actionsみたいな機構もある。Defer + CDN キャッシュ はTehilim。スキーマファースト + PHPStanで型安全 規約重視だからAIが迷わない。AI時代を前提にしたフレームワーク DB 気になったら、一度触ってみてください 34

Slide 35

Slide 35 text

ぜひチュートリアルをやってみてください Todo アプリを作りながらRelayerを体験できます https://relayer.polidog.jp/docs/todo-app 35

Slide 36

Slide 36 text

参考リンク Relayer Documentation PHPStan PHP Manual: session_cache_limiter 36

Slide 37

Slide 37 text

ご清聴ありがとうございました 37