元ネタ
深夜の思いつきで laravel/ideas から拾ってきて即席で書いてしまった。
スレ主 findOrCreateMany って書いてるけど個人的には bulkFirstOrCreate() のほうが命名近い気がするのでこれで。
やりたいこと
Model::firstOrCreate() は 1 SELECT + 1 INSERT によって実現されるが,複数レコードに対してやると無駄が多い。そのため,複数レコードを処理するときにも 1 SELECT + 1 INSERT だけで済むようにしたい。
Laravel の Eloquent Model は公式でバルクインサートをサポートしておらず,あくまでサポートしているのは Eloquent Builder と Query Builder のみであるため,タイムスタンプやオートインクリメント値を自分でセットするなど少々工夫が必要。
実装
完全に Model::firstOrCreate() を再現しようとすると困難なので, ユニーク属性は1つ という制約を設けた上で作成する。第1引数にユニークと見なすキーの名前,第2引数に属性群の配列を渡す。
/**
 * Trait BulkFirstOrCreates
 *
 * @mixin \Illuminate\Database\Eloquent\Model
 */
trait BulkFirstOrCreates
{
    /**
     * @param  string|string[]                          $uniqueKeyName
     * @param  array                                    $attributesArray
     * @return \Illuminate\Database\Eloquent\Collection
     */
    public static function bulkFirstOrCreate(string $uniqueKeyName, array $attributesArray)
    {
        $instance = new static();
        // Retrieve actually existing models
        $existingModels = $instance
            ->newQuery()
            ->whereIn($uniqueKeyName, array_column($attributesArray, $uniqueKeyName))
            ->get();
        // Mix timestamp attributes into $attributesArray entries
        if ($instance->usesTimestamps()) {
            $instance->updateTimestamps();
            $attributesArray = collect($attributesArray)
                ->map(function (array $attributes) use ($instance) {
                    return $attributes + $instance->getAttributes();
                })
                ->all();
        }
        // Create new models only from non-existent values
        $instance->newQueryWithoutRelationships()->insert(
            $nonExistentAttributesArray = collect($attributesArray)
                ->whereNotIn($uniqueKeyName, $existingModels->pluck($uniqueKeyName))
                ->all()
        );
        // Retrieve last insert ID
        $lastInsertId = (int)$instance->getConnection()->getPdo()->lastInsertId();
        // Simulate model hydration without running SELECT query
        $createdModels = $instance
            ->newModelQuery()
            ->hydrate($nonExistentAttributesArray)
            ->each(function (self $model) use (&$lastInsertId) {
                // Assign auto-increment value
                if ($model->getIncrementing()) {
                    $model->{$model->getKeyName()} = $lastInsertId++;
                    $model->syncOriginal();
                }
                // Fire "eloquent.created" event
                $model->wasRecentlyCreated = true;
                $model->fireModelEvent('created', false);
            });
        // Sort in the $valuesArray order and return as a Collection
        return $instance
            ->newCollection(array_column($attributesArray, null, $uniqueKeyName))
            ->replace($existingModels->keyBy($uniqueKeyName))
            ->replace($createdModels->keyBy($uniqueKeyName))
            ->values();
    }
}
firstOrNew() updateOrCreate() とかも含めて汎用化できそうだったら今後ライブラリ化するかも…
と思ったが, firstOrNew() なんて作っても意味ないし, updateOrCreate() は MySQL 固有文法の ELT FIELD が出てきて作りづらいのでパスかな…
使用例
class PostCode extends Model
{
    use BulkFirstOrCreates;
}
$codes = PostCode::bulkFirstOrCreate('code', [
    ['code' => '2001'],
    ['code' => '2002'],
]);
var_dump($codes[0]->id); // int(1)
var_dump($codes[0]->code); // string(4) "2001"
var_dump($codes[0]->wasRecentlyCreated); // bool(true)
var_dump($codes[1]->id); // int(2)
var_dump($codes[1]->code); // string(4) "2002"
var_dump($codes[1]->wasRecentlyCreated); // bool(true)
$codes = PostCode::bulkFirstOrCreate('code', [
    ['code' => '2000'],
    ['code' => '2001'],
    ['code' => '2002'],
    ['code' => '2003'],
]);
var_dump($codes[0]->id); // int(3)
var_dump($codes[0]->code); // string(4) "2000"
var_dump($codes[0]->wasRecentlyCreated); // bool(true)
var_dump($codes[1]->id); // int(1)
var_dump($codes[1]->code); // string(4) "2001"
var_dump($codes[1]->wasRecentlyCreated); // bool(false)
var_dump($codes[2]->id); // int(2)
var_dump($codes[2]->code); // string(4) "2002"
var_dump($codes[2]->wasRecentlyCreated); // bool(false)
var_dump($codes[3]->id); // int(4)
var_dump($codes[3]->code); // string(4) "2003"
var_dump($codes[3]->wasRecentlyCreated); // bool(true)