Produced by Fourier

Laravelライブラリモノリポ開発方法まとめ - 構成編

Hirayama icon Hirayama

弊社ではLaravel用の社内ライブラリをいくつか開発しており、そのうちの少なくとも半数近くはモノリポジトリ(モノリポ)構成で開発しています。

このやり方は、LaravelやSymfony等の有名なフレームワークでも採用されており、メリットとして、開発リポジトリが一つで済むので環境構築がラクなのと、依存関係を一元管理できるため、依存ライブラリのバージョン更新も一斉に対応することができます。

このように様々なメリットがあるモノリポ構成ですが、これを実現するには特殊な開発環境の構築やCI/CDを含めた各種設定を行う必要があって大変なうえ、あまりやる人がいないのか、解説した記事も少なめなので、安定するまでかなり苦労しました。

本記事では、そのような苦労から得たLaravelライブラリのモノリポ開発における知見を解説し、これから始めようとしている人や、現在モノリポ開発をやって困っている人向けに参考になればと思います。

ℹ️
記事が長いため、構成編(本記事)と開発編に分割しています。 構成編ではモノリポの構築と初めのライブラリ公開までを、開発編では実際の開発における数々の問題点の解消を目的に解説しています。

目標

本記事では、 sample-libawesome-lib の2つのライブラリを1つのモノリポで開発し、公開可能な状態にするまでを解説します。

1. リポジトリ作成

まずはリポジトリが無いと始まらないため、GitHub上で作成します。

必要なのは、モノリポジトリ( laravel-example-monorepo )とパッケージごとの専用リポジトリ( awesome-libsample-lib )で、合計3つのリポジトリを作る必要があります。

GitHub - FOURIER-Inc/laravel-example-monorepo thumbnail
GitHub - FOURIER-Inc/laravel-example-monorepo

Contribute to FOURIER-Inc/laravel-example-monorepo development by creating an account on GitHub.

https://github.com/FOURIER-Inc/laravel-example-monorepo

GitHub - FOURIER-Inc/awesome-lib thumbnail
GitHub - FOURIER-Inc/awesome-lib

Contribute to FOURIER-Inc/awesome-lib development by creating an account on GitHub.

https://github.com/FOURIER-Inc/awesome-lib

GitHub - FOURIER-Inc/sample-lib thumbnail
GitHub - FOURIER-Inc/sample-lib

Contribute to FOURIER-Inc/sample-lib development by creating an account on GitHub.

https://github.com/FOURIER-Inc/sample-lib

モノリポなのにパッケージごとのリポジトリを作らなければいけないのは、Composer(PackagistやSatis)はリポジトリごとに1パッケージという前提があり、モノリポのように複数のパッケージがあっても個別には認識されないという問題があります。 そこで、モノリポのGitHub Actionsでコードをパッケージごとに分割し、それぞれのリポジトリにプッシュすることで、1パッケージ1リポジトリになるようにしています。

そのため、 awesome-libsample-lib はこれ以降なにか直接操作することはありません。

この方法はLaravelの開発と同じで、Laravelでは laravel/framework リポジトリで開発を行い、GitHub Actionsで illuminateプロジェクトにあるリポジトリ にコードを分割し、その分割された各リポジトリをPackagistが読み取って公開しています。

ℹ️
Private Packagistでは、分割しなくても読み取るオプションがあるようです。

2. 初期構築

laravel-example-monorepo で初期構築をするため、以下のような composer.json ファイルを作成します。

{
    "name": "fourier/laravel-example-monorepo",
    "type": "library",
    "autoload": {
        "psr-4": {
            "Fourier\\LaravelExampleMonorepo\\": "src/"
        }
    },
    "authors": [
        {
            "name": "Ryuya Hirayama",
            "email": "ryuya.hirayama@fourier.jp"
        }
    ],
    "require": {}
}
composer.json

パッケージインストール

composer.json ファイルを作成後、開発に必要なパッケージをインストールします。

composer require illuminate/support
composer require --dev orchestra/testbench symplify/monorepo-builder
パッケージ名 説明
illuminate/support Laravelのコード
orchestra/testbench Laravelライブラリの開発環境を提供するライブラリ
symplify/monorepo-builder モノリポ構成をするのに便利なライブラリ

Workbench生成

インストール後、 ./vendor/bin/testbench workbench:install を実行してworkbenchフォルダを生成します。

workbenchフォルダは、Laravelアプリケーションを再現するためのフォルダで、

実行中様々なことを確認されますが、一旦すべて Yes と答え、 .env ファイルは .env.example を生成するようにします。

./vendor/bin/testbench workbench:install

 ┌ Run Workbench DevTool installation? ─────────────────────────┐
 │ Yes                                                          │
 └──────────────────────────────────────────────────────────────┘

 ┌ Prefix with `Workbench` namespace? ──────────────────────────┐
 │ Yes                                                          │
 └──────────────────────────────────────────────────────────────┘

  Added [Workbench\App\] for [./workbench/app] to Composer ...................................................... DONE
  Added [Workbench\Database\Factories\] for [./workbench/database/factories] to Composer ........................ DONE
  Added [Workbench\Database\Seeders\] for [./workbench/database/seeders] to Composer ............................ DONE
  Prepare [@workbench/app/Models] directory ..................................................................... DONE
  Prepare [@workbench/database/factories] directory ............................................................. DONE
  Prepare [@workbench/database/migrations] directory ............................................................ DONE
  Prepare [@workbench/database/seeders] directory ............................................................... DONE
  Prepare [@workbench/bootstrap] directory ...................................................................... DONE
  Prepare [@workbench/routes] directory ......................................................................... DONE
  Prepare [@workbench/resources/views] directory ................................................................ DONE
  File [@workbench/database/seeders/DatabaseSeeder.php] generated ............................................... DONE
  File [@workbench/routes/console.php] generated ................................................................ DONE
  File [@workbench/routes/web.php] generated .................................................................... DONE
  File [./testbench.yaml] generated ............................................................................. DONE

 ┌ Export '.env' file as? ──────────────────────────────────────┐
 │ .env.example                                                 │
 └──────────────────────────────────────────────────────────────┘

  File [@workbench/.env.example] generated ...................................................................... DONE

 ┌ Generate `workbench/bootstrap/app.php` file? ────────────────┐
 │ Yes                                                          │
 └──────────────────────────────────────────────────────────────┘

  File [@workbench/bootstrap/app.php] generated ................................................................. DONE

 ┌ Generate `workbench/bootstrap/providers.php` file? ──────────┐
 │ Yes                                                          │
 └──────────────────────────────────────────────────────────────┘

  File [@workbench/bootstrap/providers.php] generated ........................................................... DONE
  File [@laravel/database/database.sqlite] generated ............................................................ DONE

実行後は以下のようなフォルダ構成になります。

tree --gitignore
.
├── composer.json
├── composer.lock
├── src
├── testbench.yaml
└── workbench
    ├── app
    │   ├── Models
    │   │   └── User.php
    │   └── Providers
    │       └── WorkbenchServiceProvider.php
    ├── bootstrap
    │   ├── app.php
    │   └── providers.php
    ├── database
    │   ├── factories
    │   │   └── UserFactory.php
    │   ├── migrations
    │   └── seeders
    │       └── DatabaseSeeder.php
    ├── resources
    │   └── views
    └── routes
        ├── console.php
        └── web.php

3. モジュール作成

初期構築が完了したら、モジュールを作成していきます。

まずは、 src/modules フォルダを作成し、その中に各モジュールのフォルダを作成します。

.
└── src
    └── Fourier
        ├── AwesomeLib
        └── SampleLib

次に、トップレベルの composer.json にある autoload に作成したモジュールのパスを追加します。これで各モジュール内の src フォルダにあるPHPクラスや関数を use 文で使用することができるようになります。

{
  "autoload": {
    "psr-4": {
      "Fourier\\AwesomeLib\\": "src/Fourier/AwesomeLib/",
      "Fourier\\SampleLib\\": "src/Fourier/SampleLib/"
    }
  }
}

次に、各モジュールのフォルダ内で、以下のセットアップを行います。

PHPライブラリの基本

PHPライブラリは、 composer.json ファイルを読み込むため、まずはこれがないとライブラリとして認識されません。

そのため、まずは以下のような compoesr.json ファイルを作成します。作成する際は、 autoload に先ほどトップレベルの composer.json に追加したパスと同じ内容を書くことと、依存ライブラリを require に追加する必要があります。

{
    "name": "Fourier/awesome-lib",
    "description": "description",
    "license": "proprietary",
    "type": "library",
    "require": {
        "php": "^8.2",
        "illuminate/support": "^12.0"
    },
    "autoload": {
        "psr-4": {
            "Fourier\\AwesomeLib\\": ""
        }
    }
}

Laravelライブラリの基本

Laravelライブラリは、 ServiceProvider を起点に以下の機能を読み込みます。

  • config
  • migration
  • resource(blade)
  • command
  • lang

また、少し高度な機能として、 FacadeMacro といった機能もServiceProviderで追加します。

このサービスプロバイダは自動で読み込む機能がありますが、これは composer.json のextraに以下のようにServiceProviderの名前を記述することで、自動読み込みの対象になります。

{
    "extra": {
        "laravel": {
            "providers": [
                "Fourier\\AwesomeLib\\AwesomeLibServiceProvider"
            ]
        }
    }
}
awesome-libの場合

ここからモジュールごとに実装内容が異なってくるので、個別に解説してきます。

awesome-libの作成

awesome-libでは、マイグレーションで使用するBlueprintクラスに対しマクロを追加するライブラリを作成するのを目標とします。

マクロは created_atupdated_at カラムの定義とそれらのインデックスを定義するための関数を追加し、使用イメージは以下のようになります。

<?php

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('users', static function (Blueprint $table) {
            $table->id();

						// この関数を実装
            $table->timestampsWithDefault(6);
            
            // 以下の定義と同じことを上の関数1行で書ける
            $table->timestamp('created_at', 6)->useCurrent();
            $table->timestamp('updated_at', 6)->useCurrent()->useCurrentOnUpdate();

            $this->index('created_at');
            $this->index('updated_at');
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('users');
    }
}

このマクロを追加するServiceProviderは以下のようになります。

<?php

namespace Fourier\AwesomeLib;

use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\ServiceProvider;

final class AwesomeLibServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Blueprint::macro('timestampsWithDefault', function (
            ?int $precision = 0,
            string $created_at_column = 'created_at',
            string $updated_at_column = 'updated_at',
        ): void {
            /** @var Blueprint $this */
            $this->timestamp($created_at_column, $precision)
                ->useCurrent();

            $this->timestamp($updated_at_column, $precision)
                ->useCurrent()
                ->useCurrentOnUpdate();

            $this->index($created_at_column);
            $this->index($updated_at_column);
        });
    }
}

このクラスをawesome-libの src フォルダ下に作成します。

作成後の awesome-lib フォルダ構成は以下のようになっているはずです。

.
├── composer.json
└── AwesomeLibServiceProvider.php

sample-libの実装

sample-libでは、bladeファイルを共有するためのモジュールを作成します。

まずはServiceProviderを作成し、bladeファイルを読み込むように設定します。

<?php

namespace Fourier\SampleLib;

use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\ServiceProvider;

final class SampleLibServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        $this->loadViewsFrom('../resources/views', 'awesome-lib'); //viewフォルダを読み込み
    }
}

loadViewsFrom 関数では、bladeファイルが置かれているフォルダの指定と名前空間の指定します。名前空間は、指定するとライブラリ使用者は {名前空間}::{ファイル名} のようにしてbladeファイルを使用することができます。

public function get()
{
    return view('awesome-lib::index'); // awesome-libのindexを表示
}

resources/views フォルダを作成し、動作確認用のサンプルbladeファイルを作成して完了です。

<h1>Sample lib</h1>
.
├── composer.json
├── SampleLibServiceProvider.php
└── resources
    └── views
        └── index.blade.php

4. GitHubアクションの設定

最後に、今回作成したモジュールを各リポジトリを分離する処理を行うGitHubアクションを実装します。

認証情報の準備

リポジトリを分離するには複数のリポジトリへのRead/Writeアクセス権が必要になります。

アクセス権としてPersonal access tokenを発行しても良いですが、今回はより安全な一時トークンを発行する形で行います。

一時トークンを発行するには、GitHub Appsを使用する必要があります。

GitHub Appsの作成

GitHub Appsは、GitHubの機能を拡張するためのツールアプリで、IssueやPull requests等の作成などを行うことができます。

今回はそのような用途には用いず、単純に複数のリポジトリへのRead/Writeのアクセス権のみ付与したGitHub Appsを作成します。

Permissionの設定には、以下のスクリーンショットと同じ権限を与えてください。

プライベートキーの作成

GitHub Appsを作成後、次にPrivate Keyを作成します。これはリポジトリにアクセスするための一時トークンを発行する際に使用されます。

プライベートキーは、GitHub AppsのPrivate keys欄の横にある Generate a private key をクリックすることで、作成&ダウンロードされます。

シークレット変数の登録

次に、先ほど作成したPrivate keysの中身をGitHub Actionsで使用することができるように、 laravel-example-monorepo のリポジトリ設定欄を開いて、以下のシークレット変数を登録します。

Name Value
INTEGRATOR_APP_ID GitHub AppのClient ID
INTEGRATOR_APP_SECRET ダウンロードしたプライベートキーの中身

Workflow作成

認証情報を準備できたら、次にリポジトリを分離するWorkflowを作成します。

name: Split

on:
  push:
    branches:
      - main
    paths:
        - 'src/**'

jobs:
  split:
    name: Split
    runs-on: ubuntu-latest

    steps:
      - name: Generate token
        id: app-token
        uses: actions/create-github-app-token@v2
        with:
          app-id: ${{ vars.INTEGRATOR_APP_ID }}
          private-key: ${{ secrets.INTEGRATOR_APP_SECRET }}
          owner: ${{ github.repository_owner }}
          permission-contents: write
          permission-statuses: write
          permission-pull-requests: write

      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
          token: ${{ steps.app-token.outputs.token }}

      - name: Split and push each package
        run: |
          # repository list
          repos=(
            "awesome-lib:src/Modules/AwesomeLib"
            "sample-lib:src/Modules/SampleLib"
          )
          
          for entry in "${repos[@]}"; do
            repo="${entry%%:*}"
            path="${entry#*:}"
          
            echo "Processing $repo at path $path"
          
            # gitのリモートにモジュールのgitリポジトリのパスを追加
            git remote add "$repo" "https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/${{ github.repository_owner }}/${repo}.git"
            # splitsh-liteを使用して変更履歴を分離しSHA1を作成
            SHA1=$(bin/splitsh-lite --prefix="$path")
            # 分離した変更履歴をモジュールのgitリポジトリにpushする
            git push "$repo" "$SHA1:${{ github.ref }}" -f
          done

.github/workflows/split.yml

やや複雑なWorkflowですが、以下のステップで処理を行っています。

  1. 一時トークンを発行

    リポジトリへアクセスするための一時トークンを発行します。これにGitHub Appsで作成したSecretを使用します。

  2. リポジトリチェックアウト

    リポジトリをチェックアウトします。この時fetch-depthを0にして、リポジトリのすべてのコミットを取得するようにしてください。

  3. コードをリポジトリごとに分離

    最も重要な処理をしているステップになります。

    ここでは、 splitsh-lite を使用して各モジュールフォルダごとに変更履歴を元のリポジトリから分離し、分離した変更履歴をモジュールのgitリポジトリにpushする処理をしています。

このWorkflowによって、モノリポの変更が各モジュールのリポジトリに分離されます。

分離後は一般的なPHP(Laravel)ライブラリと同じ構成になっているawesome-libとsample-libリポジトリが作成されています。

後は、これをpackagistやプライベートレジストリに登録することでパッケージが公開され、誰でも利用することができるようになります。

まとめ

本記事では、ゼロからモノリポ構成のライブラリ開発について説明しました。

本記事の内容でライブラリの開発は可能ですが、テスト方法やブラウザでの確認、 laravel-ide-helper などといった開発ツールなどとの連携ができるとより効率的です。

そのため、開発編ではそのあたりを解説記事を書くため、本記事で興味が出たらぜひ読んでいただければと思います。

Hirayama icon

Hirayama slash forward icon Engineer

業務では主にPHPやTypeScriptを使用したバックエンドアプリケーションやデスクトップアプリケーションの開発をしています。趣味は登山。