【Laravel】ローカルパッケージを利用した開発戦略　その２：実践編


:::note
この記事は　その１　の続きです。
単体としても読めなくは無いと思います。
:::

https://qiita.com/sigeta/items/ff46658f09b407f70b85




## Spatie パッケージの開発ガイド

公式のトレーニングが確実だが、それを見ていない私の Tips を以下に記録しておく。
main と sub を行ったり来たりするので、shell の先頭に必ず `cd` を入れてある。




## ファイル構成

![drawio (24).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3833190/53766213-26c5-fded-bfff-59515edff54b.png)

ざっとこんな感じ。
エントリポイントは、パッケージ名と同じファイル。
こいつをライブラリみたいにしていく。

main への登録は ServiceProvider が全てやってくれる。
何か公開したいものは、ここで登録する。

ほか route や model が必要であれば、好きなように増やしてOK。
(artisan コマンドが使用できないので、その点は注意)
(いくつかのコマンドは composer script に用意されている)

:::note
`namespace` は、 `src/` が `Crest\SubModule` に紐づいている。
:::




## ServiceProvicer の設定

パッケージの設定、紐づけ処理は、この ServiceProvider が全て受け持っている。
`package/sub-module/src/SubModuleServiceProvider.php`

初期のものだけ紹介する。

```php
{
    $package
        ->name('sub-module')                      // パッケージの名前
        ->hasConfigFile()                         // config ファイルを利用するか
        ->hasViews()                              // view ファイルを利用するか
        ->hasMigration('create_sub_module_table') // migration ファイルを利用するか
        ->hasCommand(SubModuleCommand::class);    // command を利用するか
}
```

設定次第で、自動 route 紐づけ、自動 publish などもできる。（が今回は紹介しない）
詳しくはこちらへ。

https://github.com/spatie/laravel-package-tools

基本的に何かファイルを増やしたら、ここに紐づける。




## 機能の追加方法

消費税計算の機能を追加してみる。
テストも合わせて追加する。

### 機能追加

```php:package/sub-module/src/SubModule.php
<?php

namespace Crest\SubModule;

class SubModule {
    /**
     * 消費税額(VAT) を算出する
     *
     * @param integer $price
     * @return integer 四捨五入した整数
     */
    public function calcVat(int $price): int
    {
        return round($price * 0.1, 0);
    }
}
```


### テスト

```php:package/sub-module/tests/ExampleTest.php
<?php

use Crest\SubModule\SubModule;

it('calc VAT', function () {
    expect((new SubModule)->calcVat(205))
        ->toBe(21);

    expect((new SubModule)->calcVat(204))
        ->toBe(20);
});
```

テストを実行する。

```bash
$ cd package/sub-module

$ composer test
> vendor/bin/pest
   PASS  Tests\ArchTest
  ✓ it will not use debugging functions

   PASS  Tests\ExampleTest
  ✓ it calc VAT

  Tests:    2 passed (5 assertions)
  Duration: 0.10s
```



### main から呼び出す

簡略化のため、コマンドから呼び出す。

```bash
$ cd ../../

$ php artisan make:command AppVat
```

Facade も自動的に公開されているので、好きなほうを使う。

```php:app/Console/Commands/AppVat.php
<?php

namespace App\Console\Commands;

use Crest\SubModule\SubModule;
use Illuminate\Console\Command;

class AppVat extends Command
{
    protected $signature = 'app:vat {price}';

    protected $description = 'calc vat';

    public function handle()
    {
        $price = $this->argument('price');
        $vat = (new SubModule)->calcVat($price);

        $this->info('VAT: '.$vat);

        return self::SUCCESS;
    }
}
```

```bash
$ cd ../../
 
$ php artisan app:vat 105
VAT: 11
```

かんぺき！ :raised_hands: :raised_hands: :raised_hands:





## モデル + マイグレーション の追加方法

一言メモを保存する機能を追加してみる。

### 機能追加

migration ファイルは以下のように、スタブ形式で保存されている。
`package/sub-module/database/migrations/create_sub_module_table.php.stub`

これは Laravel のライブラリを使ったことがあると分かりやすいが、`vendor:publish` で、main 側の migration フォルダにコピーして配置するためなのです。

テーブル名は他と被らないような名前にしよう。

```php:package/sub-module/database/migrations/create_sub_module_table.php.stub
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up()
    {
        Schema::create('crest_microblogs', function (Blueprint $table) {
            $table->id();
            $table->text('message');
            $table->timestamps();
        });
    }

    public function down()
    {
        Schema::dropIfExists('crest_microblogs');
    }
};
```

テーブル名を変えたら、ファイル名と ServiceProvider への紐づけも変えておこう。

```diff_php:package/sub-module/src/SubModuleServiceProvider.php
- ->hasMigration('create_sub_module_table')
+ ->hasMigration('create_crest_microblogs_table')
```


Model は初期ディレクトリが無いので、`src/Models/*` に作成する。

```php:package/sub-module/src/Models/Microblog.php
<?php

namespace Crest\SubModule\Models;

use Illuminate\Database\Eloquent\Model;

class Microblog extends Model
{
    protected $table = 'crest_microblogs';

    protected $fillable = ['message'];
}
```

必要であれば Factory ファイルを作成する。


最後に機能を追加する。

```php:package/sub-module/src/SubModule.php
<?php

namespace Crest\SubModule;

class SubModule {
    /**
     * 一言メモを追加する.
     *
     * @param string $message メモ
     * @return Microblog
     */
    public function addBlog(string $message): Microblog
    {
        return Microblog::create([
            'message' => $message,
        ]);
    }

    /**
     * 一言メモを取得する.
     *
     * @param integer $id メモID
     * @return Microblog
     */
    public function getBlog(int $id): Microblog|null
    {
        return Microblog::where('id', $id)->first();
    }
}
```



### テスト

マイグレーションが必要なテストは、ちょっとコツが必要。
`TestCase.php` の中の `Setup` で利用する migration を手動登録しなければならない。
(Laravel 起動してないからね、仕方ないね)


```diff_php:package/sub-module/tests/TestCase.php
  public function getEnvironmentSetUp($app)
  {
      config()->set('database.default', 'testing');

-     /*
-     $migration = include __DIR__.'/../database/migrations/create_sub-module_table.php.stub';
-     $migration->up();
-     */
+     $migration = include __DIR__.'/../database/migrations/create_sub-create_crest_microblogs_table.php.stub';
+     $migration->up();
  }
```

```php:package/sub-module/tests/ExampleTest.php
<?php

use Crest\SubModule\SubModule;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;

beforeEach(function () {
    uses(RefreshDatabase::class);

    Carbon::setTestNowAndTimezone(
        Carbon::parse('2025-01-23 01:23:45'),
    );
});

it('add microblog', function () {
    $message = '朝ごはんは目玉焼きでした。';

    $blog = (new SubModule)->addBlog($message);

    expect($blog->toArray())
        ->toMatchArray([
            'id' => 1,
            'message' => $message,
            'updated_at' => '2025-01-23T01:23:45.000000Z',
            'created_at' => '2025-01-23T01:23:45.000000Z',
        ]);

    expect((new SubModule)->getBlog($blog->id))
        ->toMatchArray($blog->toArray());
});
```

テストを実行する。

```bash
$ cd package/sub-module

$ composer test
> vendor/bin/pest
   PASS  Tests\ArchTest
  ✓ it will not use debugging functions   

   PASS  Tests\ExampleTest
  ✓ it add microblogs      
  ✓ it calc VAT

  Tests:    3 passed (21 assertions)
  Duration: 0.12s
```


### main から呼び出す

`migration` を main で呼び出す場合、`vendor:publish` を行う必要がある。
詳しくは readme に書いてある。
`package/sub-module/README.md`  

```bash
$ cd ../../

$ php artisan vendor:publish --tag="sub-module-migrations"
$ php artisan migrate
```

そして、コマンドから呼び出す。

```bash
$ cd ../../

$ php artisan make:command AppMicroblog
```

```php:app/Console/Commands/AppMicroblog.php
<?php

namespace App\Console\Commands;

use Crest\SubModule\SubModule;
use Illuminate\Console\Command;

class AppVat extends Command
{
    protected $signature = 'app:vat {price}';

    protected $description = 'calc vat';

    public function handle()
    {
        $price = $this->argument('price');
        $vat = (new SubModule)->calcVat($price);

        $this->info('VAT: '.$vat);

        return self::SUCCESS;
    }
}
```

```bash
$ cd ../../
 
$ php artisan app:microblog "早起きした"
{"message":"早起きした","updated_at":"2025-01-09T04:13:06.000000Z","created_at":"2025-01-09T04:13:06.000000Z","id":3}
```

かんぺき！ :raised_hands: :raised_hands: :raised_hands:



### migration の更新時の対応

`vendor:publish` を使用する場合、ファイルをコピーする都合、変更がリアルタイムには反映されなくなる。
基本的には、別のマイグレーションファイルを作って、そこに変更を書き込む。
元のファイルに書き込む場合は、publish したファイルを手動で削除する必要がある。

実例は次の章へ






## モデルに「ユーザー」とのリレーションを作成する

パッケージにした時点で他のデータと関連することはそこまで無い。
ただ「ユーザー」を使いたい場面は多いのではないか。

先ほどの一言機能に、書いた人を追加する。

### 機能追加

```php:package/sub-module/database/migrations/append_author_by_crest_microblogs_table.php.stub
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up()
    {
        Schema::table('crest_microblogs', function (Blueprint $table) {
            $table->bigInteger('author_by')->nullable();
        });
    }

    public function down()
    {
        Schema::table('crest_microblogs', function (Blueprint $table) {
            $table->dropColumn('author_by');
        });
    }
};
```

ServiceProvider への紐づけを増やしておく。

```diff_php:package/sub-module/src/SubModuleServiceProvider.php
  ->hasMigration('create_crest_microblogs_table')
+ ->hasMigration('append_author_by_crest_microblogs_table')
```

Model も書き換えておく。
この時、`User` テーブルと紐づけるには、`config('auth.providers.users.model')` を使うと良い。
main の config を見ると分かるが、認証時に利用する User Model が登録されている。

紐づけるカラム名など、柔軟に変更したい場合は、後述の `config` と併用し、main から書き換えられるようにすると良い。

```diff_php:package/sub-module/src/Models/Microblog.php
  class Microblog extends Model
  {
      protected $table = 'crest_microblogs';
  
-     protected $fillable = ['message'];
+     protected $fillable = ['message', 'author_by'];

+     public function author(): BelongsTo
+     {
+         return $this->belongsTo(config('auth.providers.users.model'), 'author_by');
+     }
  }
```

最後に機能を書き換える。

```diff_php:package/sub-module/src/SubModule.php
+ use Illuminate\Support\Facades\Auth;

  public function addBlog(string $message): Microblog
  {
-     return Microblog::create([
-         'message' => $message,
-         'author_by' => Auth::id(),
-     ]);

+     $blog = Microblog::create([
+         'message' => $message,
+         'author_by' => Auth::id(),
+     ]);
+      return $blog->load(['author']);
  }

  public function getBlog(int $id): Microblog|null
  {
-     return Microblog::where('id', $id)->first();
+     return Microblog::with(['author'])->where('id', $id)->first();
  }
```



### テスト

TestCase に migration を登録する。

```diff_php:package/sub-module/tests/TestCase.php
  public function getEnvironmentSetUp($app)
  {
      config()->set('database.default', 'testing');

      $migration = include __DIR__.'/../database/migrations/create_sub-create_crest_microblogs_table.php.stub';
      $migration->up();
      
+     $migration = include __DIR__.'/../database/migrations/append_author_by_crest_microblogs_table.php.stub';
+     $migration->up();
  }
```

テストは User ロジックをモックする必要があるのでちょっと大変。

```php:package/sub-module/tests/ExampleTest.php
<?php

use Crest\SubModule\SubModule;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Schema;

beforeEach(function () {
    uses(RefreshDatabase::class);

    Carbon::setTestNowAndTimezone(
        Carbon::parse('2025-01-23 01:23:45'),
    );
});

it('add microblog with author', function () {
    class User extends Illuminate\Foundation\Auth\User {
        protected $table = 'users';
        protected $fillable = ['name'];
    }
    Config::set('auth.providers.users.model', User::class);

    Schema::create('users', function (Blueprint $table) {
        $table->id();
        $table->string('name');
        $table->timestamps();
    });

    $author = User::create(['name' => '太宰治']);
    $this->actingAs($author);

    ///

    $message = '朝ごはんは目玉焼きでした。';

    $blog = (new SubModule)->addBlog($message);

    expect($blog->toArray())
        ->toMatchArray([
            'id' => 1,
            'message' => $message,
            'author_by' => $author->id,
            'updated_at' => '2025-01-23T01:23:45.000000Z',
            'created_at' => '2025-01-23T01:23:45.000000Z',
        ]);

    expect((new SubModule)->getBlog($blog->id))
        ->toMatchArray($blog->toArray());
});
```

テストを実行する。

```bash
$ cd package/sub-module

$ composer test
> vendor/bin/pest
   PASS  Tests\ArchTest
  ✓ it will not use debugging functions

   PASS  Tests\ExampleTest
  ✓ it add microblog with author
  ✓ it calc VAT

  Tests:    3 passed (25 assertions)
  Duration: 0.12s
```


### main から呼び出す

上記と同じように `vendor:publish` を行う。
そうすると、新しいファイルのみ出力される。

```bash
$ cd ../../

$ php artisan vendor:publish --tag="sub-module-migrations"
$ php artisan migrate
```

そして、上記で作成したコマンドを編集して呼び出す。

```diff_php:app/Console/Commands/AppMicroblog.php
+ use App\Models\User;
+ use Illuminate\Support\Facades\Auth;

  public function handle()
  {
      $message = $this->argument('message');
      
+     $user = User::find(1) ?? User::factory()->create(['name' => 'たろう']);
+     Auth::login($user);

      $blog = (new SubModule)->addBlog($message);

      $this->info($blog->toJson(JSON_UNESCAPED_UNICODE));
      return self::SUCCESS;
  }
```

```bash
$ cd ../../
 
$ php artisan app:microblog "早寝した。"
{"message":"早寝した。","author_by":1,"updated_at":"2025-01-09T05:04:32.000000Z","created_at":"2025-01-09T05:04:32.000000Z","id":8,"author":{"id":1,"name":"たろう","email":"willard95@example.com","email_verified_at":"2025-01-09T04:59:35.000000Z","created_at":"2025-01-09T04:59:35.000000Z","updated_at":"2025-01-09T04:59:35.000000Z"}}
```

かんぺき！ :raised_hands: :raised_hands: :raised_hands:






## config の作り方

最後に config についてふわっと触れておく。
こちらも `vendor:publish` 対象のものである。

`package/sub-module/config/sub-module.php` にデフォルトの設定を書いておく。
publish しない状態であれば、デフォルトの設定が読み込まれる。

ローカルパッケージの場合は、必要最低限の設定で大丈夫。

```bash
$ cd ../../

$ php artisan vendor:publish --tag="sub-module-config"
```


## おわりに

一通りパッケージ開発に必要な機能に触れた。
view や route は触れない。
このプロジェクトはテンプレートなので、使わないものは消しておこう。  


パッケージ開発、そんなに難しくなくてビックリ。
ローカルパッケージ戦略、試してみてください。


## 参考

- Composer Local Package
  - https://getcomposer.org/doc/05-repositories.md
  - https://stackoverflow.com/questions/29994088/composer-require-local-package
- Laravel のパッケージドキュメント
  - https://readouble.com/laravel/11.x/ja/packages.html
  - https://www.laravelpackage.com/
  - https://github.com/orchestral/testbench
- Spatie のパッケージドキュメント
  - https://github.com/spatie/package-skeleton-laravel
  - https://github.com/spatie/laravel-package-tools