Laravel用のComposerパッケージを自作して公開してみた

## やりたかっとこと

LaravelのEloquent Builderで、LIKE検索を以下のように記述できるクラスを作った。
```php:UserController.php
$words = ['山田', '岐阜市'];
$comulns = ['name', 'company.address'];
User::whereLike($columns, $words)->get();  // 岐阜市で働く山田さんを取得
```

これをいろんなプロジェクトで使いたいので、パッケージ化して`composer require`で利用できるようにしたい。

## 結果

ここにパッケージをリリースして、

https://github.com/oor30/laravel-where-like

以下コマンドで利用可能に。

```shell
composer require kazuki/laravel-where-like
```

## パッケージのロジック

Laravel標準機能のMixinを利用して、`Illuminate\Database\Eloquent\Builder`クラスに`whereLike`メソッドを追加しています。
※Mixinは`\Illuminate\Support\Traits\Macroable.php`トレイトを利用しているクラスに、メソッドを後づけできる機能です。

:::note alert
標準機能とはいえ、既存クラスの仕様を外部パッケージが変更するのはよろしく無いかもしれません。その是非はここでは一旦おいておきます。
:::


## 全体の流れ

1. GitHubリモートレポジトリを作成
1. Composerパッケージを作成
1. GitHubにリリース
1. PackagistにSubmit

すべて以下のサイトに記載されています。この記事では、2.でMixinを有効にできた手順を紹介します。

https://kinsta.com/jp/blog/create-composer-package/

## ディレクトリ構成

`composer init`で作成されたディレクトリに、ソースを追加&composer.jsonの変更をしていきます。

```
/
├─ .git
│  └─ ...
├─ src
│  ├─ BuilderMixin.php // 追加
│  └─ WhereLikeServiceProvider.php // 追加
├─ vendor
│  └─ ...
├─ composer.json // 編集
├─ composer.lock
├─ LICENSE
└─ README.md
```

### src

<details><summary>BuilderMixin.php(メソッドの実装)</summary>

```php:BuilderMixin.php
<?php

namespace Kazuki\LaravelWhereLike;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;

class BuilderMixin
{
    public function whereLike()
    {
        /**
         * あいまい検索
         *
         * @param  array|string  $attributes 検索対象とするカラム名。リレーション先のカラム名を指定する場合は、テーブル名とカラム名を`.`で連結する（複数リレーション連結可能）。
         * @param  array|string  $keywords 検索値。配列の場合は、各検索値をor条件で連結する。
         * @param  int  $position `-1` => 前方一致 `1` => 後方一致 `0` => 部分一致（デフォルトは`0`）
         * @param  string  $boolean `and`か`or`を指定。デフォルトは`and`。
         * @return \Illuminate\Database\Eloquent\Builder
         */
        return function (array|string $attributes, array|string $keywords, int $position = 0, $boolean = 'and') {
            // 検索値が配列の場合、再帰呼び出し。
            // 検索値同士は、or検索で連結する。
            if (is_array($keywords)) {
                /** @var Builder $this */
                return $this->where(function (Builder $query) use ($attributes, $keywords, $position) {
                    foreach ($keywords as $keyword) {
                        $query->whereLike($attributes, $keyword, $position, 'or');
                    }
                }, boolean: $boolean);  // and・or 条件
            }

            // SQLインジェクション対策
            $keywords = addcslashes($keywords, '\_%');

            // 検索位置
            $condition = [
                1 => "{$keywords}%",
                -1 => "%{$keywords}",
            ][$position] ?? "%{$keywords}%";

            // カラム名が単体（文字列）で与えられた場合、配列に変換
            if (! is_array($attributes)) {
                $attributes = [$attributes];
            }

            /** @var Builder $this */
            return $this->where(function (Builder $query) use ($attributes, $condition) {
                foreach (Arr::wrap($attributes) as $attribute) {
                    $query->when(
                        // $attributeが、'.'（リレーション）を含むか確認
                        ! ($attribute instanceof \Illuminate\Contracts\Database\Query\Expression) &&
                        str_contains((string) $attribute, '.'),
                        // '.'（リレーション）を含む場合
                        function (Builder $query) use ($attribute, $condition) {
                            // $attributeを'.'でリレーションとカラム名に分割
                            $attrs = explode('.', (string) $attribute);
                            $relatedAttribute = array_pop($attrs);
                            $relation = implode('.', $attrs);
                            $query->orWhereRelation($relation, $relatedAttribute, 'LIKE', $condition);
                        },
                        // '.'（リレーション）を含まない場合
                        function (Builder $query) use ($attribute, $condition) {
                            $query->orWhere($attribute, 'LIKE', $condition);
                        }
                    );
                }
            }, boolean: $boolean);  // and・or 条件
        };
    }

    public function orWhereLike()
    {
        /**
         * あいまい検索（or）
         *
         * @param  array|string  $attributes 検索対象とするカラム名。リレーション先のカラム名を指定する場合は、テーブル名とカラム名を`.`で連結する。
         * @param  array|string  $keywords 検索値
         * @param  int  $position `-1` => 前方一致 `1` => 後方一致 `0` => 部分一致（デフォルトは`0`）
         * @return \Illuminate\Database\Eloquent\Builder
         */
        return function (array|string $attributes, array|string $keywords, int $position = 0) {
            /** @var Builder $this */
            return $this->whereLike($attributes, $keywords, $position, 'or');
        };
    }
}
```
</details>

ServiceProviderの継承クラスを作成して、`boot`メソッド内で`Builder`クラスにメソッドを追加します。

```php:WhereLikeServiceProvider.php
<?php

namespace Kazuki\LaravelWhereLike;

use Illuminate\Support\ServiceProvider;
use Illuminate\Database\Eloquent\Builder;

class WhereLikeServiceProvider extends ServiceProvider
{
    public function boot()
    {
        Builder::mixin(new BuilderMixin());  // Builderにメソッドを追加
    }
}
```

### Composer.json

上の参考記事で作成したファイルに依存関係とServicePoroviderを追記。
```json:composer.json
{
    "name": "kazuki/laravel-where-like",
    "description": "Add where like query to laravel eloquent builder.",
    "type": "library",
    "license": "MIT",
    
    "autoload": {
        "psr-4": {
            "Kazuki\\LaravelWhereLike\\": "src/"
        }
    },
    "authors": [
        {
            "name": "oor30",
            "email": "kazuki.w.0920@gmail.com"
        }
    ],
    "minimum-stability": "stable",
    
    // 以下を追記（※実際にJSONにコメントを記載してはいけません）
    "require": {
        "php": ">=8.0",
        "illuminate/database": "^9.0 || ^10.0"
    },
    "extra": {
        "laravel": {
            "providers": [
                "Kazuki\\LaravelWhereLike\\WhereLikeServiceProvider"
            ]
        }
    }
}
```