LaravelでFirebaseに繋いでタスクスケジューラーで定期的に何かしらの処理をさせる

## はじめに

LaravelからFirebase（Firestore）に接続し、定期的な処理をタスクスケジューラー（Laravelのスケジューラー）で実行する方法をまとめました。

今回は以下のようなユースケースを想定します：

- Firestoreに保存されたデータを定期的に読み取って処理
- 条件に合致するデータに対してメール通知やフラグ更新などのバッチ処理
- GCP SDKではなく、REST API経由でアクセス（Laravelから簡単に呼び出す）

## LaravelとFirebaseの相性について

LaravelはもともとMySQLやPostgreSQLなどのRDBと親和性の高いPHPフレームワークですが、Firebase（特にFirestore）はNoSQL型のスキーマレスなドキュメントDBです。

このため、Eloquent ORMなどLaravel標準のDB操作機能とは異なるアプローチが求められます。しかし、以下の点でFirebaseとLaravelは十分に組み合わせ可能です：

- Laravelの柔軟なサービス構造とDI（依存性注入）でFirebase SDKやREST APIのラップが容易
- LaravelのスケジューラーやQueueなどのバックグラウンド処理機能とFirestoreのイベント的なデータ構造が親和性あり
- Firebase AuthenticationやStorageとの連携もHTTPベースで行えるため、LaravelのHTTPクライアントで対応可能

**注意点：**
- リアルタイム同期やトリガーベースの処理（Cloud Functionsなど）については、LaravelよりもNode.jsやFirebase自身との相性が良い
- LaravelからFirestoreを使う場合、SDKベースよりREST APIベースの方が手軽（ただし制限もある）

## 構成概要

- Laravel 10.x
- Firebase（Firestore）
- Laravelの `Schedule` 機能（cron）
- Guzzle HTTP client使用

2025年5月時点での私が利用している`Laravelのバージョン`と`composer.json（一部）`はこちら
```
% php artisan --version
Laravel Framework 10.48.28
```
```json:composer.sjon
    "require": {
        "php": "^8.1",
        "google/auth": "^1.47",
        "guzzlehttp/guzzle": "^7.2",
        "kreait/firebase-php": "^7.16",
        "laravel/framework": "^10.10",
        "laravel/sanctum": "^3.3",
        "laravel/tinker": "^2.8"
    }
```
※バージョンは作業時に各自で確認してください。

## ステップ1: Firebaseの認証情報を取得
まずはFirebaseのプロジェクトを作成し秘密鍵を取得します。
Firebaseは[こちら](https://console.firebase.google.com/u/0/?hl=ja&pli=1)からアクセスできます。

1. Firebase Console → 「プロジェクトの設定」
2. 「サービスアカウント」 → 新しい秘密鍵を生成
3. ダウンロードしたJSONを保存

## ステップ2: ステップ2: LaravelでFirestoreクライアントを設定
`guzzlehttp/guzzle`をプロジェクトに追加します。
```bash
composer require guzzlehttp/guzzle
```

`app/Services/FirestoreService.php`を作成します。
```php:app/Services/FirestoreService.php
<?php

namespace App\Services;

use GuzzleHttp\Client;
use Illuminate\Support\Facades\Storage;
use DateTime;

class FirestoreService
{
    private $client;
    private $projectId;

    public function __construct()
    {
        $credentials = json_decode('ダウンロードしたJSONファイル', true);
        $this->projectId = $credentials['project_id'];

        $this->client = new Client([
            'base_uri' => 'FirestoreのベースURL',
            'headers' => ['Content-Type' => 'application/json'],
        ]);
    }
    
    private function getAccessToken()
    {
        $credentialsPath = storage_path('ダウンロードしたJSONファイル');

        $url = 'https://oauth2.googleapis.com/token';
        $scope = 'https://www.googleapis.com/auth/datastore';
        $credentials = json_decode(file_get_contents($credentialsPath), true);

        $client = new \GuzzleHttp\Client();
        $response = $client->post($url, [
            'form_params' => [
                'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
                'assertion' => $this->generateJwtAssertion($credentials, $scope),
            ]
        ]);

        return json_decode($response->getBody(), true)['access_token'];
    }

    private function generateJwtAssertion($credentials, $scope)
    {
        $header = base64_encode(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
        $now = time();
        $claimSet = base64_encode(json_encode([
            'iss' => $credentials['client_email'],
            'scope' => $scope,
            'aud' => 'https://oauth2.googleapis.com/token',
            'exp' => $now + 3600,
            'iat' => $now,
        ]));

        // 秘密鍵を正しい形式に変換
        $privateKey = $credentials['private_key'];
        $privateKey = str_replace("\\n", "\n", $privateKey);
        $privateKeyResource = openssl_pkey_get_private($privateKey);

        if ($privateKeyResource === false) {
            throw new \Exception('Invalid private key: Unable to parse');
        }

        $signature = '';
        openssl_sign("$header.$claimSet", $signature, $privateKeyResource, 'sha256WithRSAEncryption');
        $signature = base64_encode($signature);

        return "$header.$claimSet.$signature";
    }

    /**
     * データをFirestoreのフォーマットに変換
     */
    private function formatData(array $data)
    {
        $formatted = ['fields' => []];
        foreach ($data as $key => $value) {
            if (is_string($value)) {
                $formatted['fields'][$key] = ['stringValue' => $value];
            } elseif (is_int($value)) {
                $formatted['fields'][$key] = ['integerValue' => $value];
            } elseif (is_float($value)) {
                $formatted['fields'][$key] = ['doubleValue' => $value];
            } elseif (is_bool($value)) {
                $formatted['fields'][$key] = ['booleanValue' => $value];
            } elseif (is_array($value)) {
                $formatted['fields'][$key] = ['arrayValue' => ['values' => array_map(fn($v) => ['stringValue' => $v], $value)]];
            } elseif ($value instanceof DateTime) {
                $formatted['fields'][$key] = ['timestampValue' => $value->format(DateTime::ATOM)];
            }
        }
        return $formatted;
    }

    /**
     * Firestore JSON形式に変換
     */
    private function formatFirestoreFields(array $data)
    {
        $fields = [];
        foreach ($data as $item) {
            $path = $item['path'];
            $value = $item['value'];

            if (is_string($value)) {
                $fields[$path] = ['stringValue' => $value];
            } elseif (is_int($value)) {
                $fields[$path] = ['integerValue' => $value];
            } elseif (is_float($value)) {
                $fields[$path] = ['doubleValue' => $value];
            } elseif (is_bool($value)) {
                $fields[$path] = ['booleanValue' => $value];
            } elseif (is_array($value)) {
                $fields[$path] = ['arrayValue' => ['values' => array_map(fn($v) => ['stringValue' => $v], $value)]];
            }
        }
        return $fields;
    }

    /**
     * ドキュメントを取得
     */
    public function getDocument($collection, $document)
    {
        $accessToken = $this->getAccessToken();

        $response = $this->client->get("$collection/$document", [
            'headers' => ['Authorization' => "Bearer {$accessToken}"],
        ]);

        return json_decode($response->getBody(), true);
    }

    /**
     * ドキュメントを条件付きで取得 (where句)
     */
    public function getDocumentsWhere($collection, array $conditions)
    {
        $accessToken = $this->getAccessToken();

        // where条件をJSON構造に変換
        $structuredQuery = [
            'structuredQuery' => [
                'from' => [['collectionId' => $collection]],
                'where' => $this->buildWhereClause($conditions)
            ]
        ];

        $response = $this->client->post(":runQuery", [
            'headers' => ['Authorization' => "Bearer {$accessToken}"],
            'json' => $structuredQuery,
        ]);

        return array_map(fn($doc) => $doc['document'] ?? null, json_decode($response->getBody(), true));
    }

    /**
     * where条件を構築
     */
    private function buildWhereClause(array $conditions)
    {
        $clauses = [];
        foreach ($conditions as $condition) {
            $clauses[] = [
                'fieldFilter' => [
                    'field' => ['fieldPath' => $condition['field']],
                    'op' => $this->convertOperator($condition['operator']),
                    'value' => $this->formatWhereValue($condition['value'])
                ]
            ];
        }

        return count($clauses) > 1
            ? ['compositeFilter' => ['op' => 'AND', 'filters' => $clauses]]
            : $clauses[0];
    }

    /**
     * Firestoreの比較演算子に変換
     */
    private function convertOperator($operator)
    {
        return match ($operator) {
            '=' => 'EQUAL',
            '>' => 'GREATER_THAN',
            '>=' => 'GREATER_THAN_OR_EQUAL',
            '<' => 'LESS_THAN',
            '<=' => 'LESS_THAN_OR_EQUAL',
            '!=' => 'NOT_EQUAL',
            'array-contains' => 'ARRAY_CONTAINS',
            'in' => 'IN',
            'array-contains-any' => 'ARRAY_CONTAINS_ANY',
            default => throw new \InvalidArgumentException("Unsupported operator: $operator")
        };
    }

    /**
     * Firestore用にwhereの値をフォーマット
     */
    private function formatWhereValue($value)
    {
        return match (true) {
            is_string($value) => ['stringValue' => $value],
            is_int($value) => ['integerValue' => $value],
            is_float($value) => ['doubleValue' => $value],
            is_bool($value) => ['booleanValue' => $value],
            is_array($value) => ['arrayValue' => ['values' => array_map(fn($v) => ['stringValue' => $v], $value)]],
            default => throw new \InvalidArgumentException("Unsupported value type")
        };
    }

    /**
     * ドキュメント追加
     */
    public function addDocument($collection, array $data)
    {
        $accessToken = $this->getAccessToken();

        $response = $this->client->post($collection, [
            'headers' => ['Authorization' => "Bearer {$accessToken}"],
            'json' => $this->formatData($data), // フォーマット済みのデータを送信
        ]);

        return json_decode($response->getBody(), true);
    }
    
    /**
     * ドキュメントを更新 (部分更新 = merge)
     */
    public function updateDocument($collection, $document, array $data)
    {
        $accessToken = $this->getAccessToken();

        // 更新するフィールド名を取得 (クエリパラメータ用に)
        $fieldPaths = array_map(fn($item) => $item['path'], $data);

        // FirestoreのJSON形式に変換
        $fields = [];
        foreach ($data as $item) {
            $path = $item['path'];
            $value = $item['value'];

            if (is_string($value)) {
                $fields[$path] = ['stringValue' => $value];
            } elseif (is_int($value)) {
                $fields[$path] = ['integerValue' => $value];
            } elseif (is_float($value)) {
                $fields[$path] = ['doubleValue' => $value];
            } elseif (is_bool($value)) {
                $fields[$path] = ['booleanValue' => $value];
            } elseif (is_array($value)) {
                $fields[$path] = ['arrayValue' => ['values' => array_map(fn($v) => ['stringValue' => $v], $value)]];
            } elseif ($value instanceof DateTime) {
                $fields[$path] = ['timestampValue' => $value->format(DateTime::ATOM)];
            }
        }

        // クエリパラメータを複数回設定
        $query = [];
        foreach ($fieldPaths as $field) {
            $query[] = 'updateMask.fieldPaths=' . urlencode($field);
        }

        $url = "$collection/$document?" . implode('&', $query);

        $response = $this->client->patch($url, [
            'headers' => ['Authorization' => "Bearer {$accessToken}"],
            'json' => ['fields' => $fields],
        ]);

        return json_decode($response->getBody(), true);
    }

    /**
     * ドキュメントを削除
     */
    public function deleteDocument($collection, $document)
    {
        $accessToken = $this->getAccessToken();

        $this->client->delete("$collection/$document", [
            'headers' => ['Authorization' => "Bearer {$accessToken}"],
        ]);

        return true;
    }
}
```
上記コードで`データ取得`,`データ追加`,`データ更新`,`データ削除`が可能になります。

## ステップ3: コマンドの作成
実際にFirebase（Firestore）にアクセスして処理を行うコードを実装します。

```bash
php artisan make:command ProcessFirestoreTasks
```

```php:app/Console/Commands/ProcessFirestoreTasks.php
namespace App\Console\Commands;

use Illuminate\Console\Command;
use App\Services\FirestoreService;

class ProcessFirestoreTasks extends Command
{
    protected $signature = 'firebase:process-tasks';
    protected $description = 'Firestoreのタスクを処理';

    private FirestoreService $firestore;

    public function __construct(FirestoreService $firestore)
    {
        parent::__construct();
        $this->firestore = $firestore;
    }
    
    public function handle(FirestoreService $firestore)
    {
        // データ取得
        $result = $this->firestore->getDocumentsWhere('コレクション', 'コレクションID');

        // データ取得（条件付き）
        $documents = $this->firestore->getDocumentsWhere('コレクション', [
            ['field' => 'フォールド名', 'operator' => '条件（>や=、>=など）', 'value' => '値'],
        ]);
        $result = collect($documents)->map(function ($doc) {
            return [
                'uid' => $doc['fields']['uid']['stringValue'] ?? null,
                'value' => $doc['fields']['value']['stringValue'] ?? null,
            ];
        })->toArray();

        // データ追加
        $this->firestore->addDocument('コレクション', [
            "フォールド名" => "値",
        ]);

        // データ更新
        $this->firestore->updateDocument('コレクション', 'コレクションID', [
            ['path' => 'フォールド名', 'value' => '値'],
        ]);

        // データ削除
        $this->firestore->deleteDocument('コレクション', 'コレクションID');
    }
}
```
Laravel（php）からFirebaseのようなNoSQLを触る時、UpdateやWhere句を使うときは少し書き方が変わるので注意が必要です。

## ステップ4: スケジューラーに登録
実装した処理をタスクスケジューラーに追加します。
```php:app/Console/Kernel.php
protected function schedule(Schedule $schedule)
{
    $schedule->command('firebase:process-tasks')->everyMinute();
}
```

タスクスケジューラーを利用できるよう`cron`を設定します。
```
* * * * * cd /path/to/laravel && php artisan schedule:run >> /dev/null 2>&1
```

## 終わりに
LaravelとFirebaseは、構造的には異なるものの、REST API経由で連携すれば十分に実用的な構成が可能です。

Laravel側にタスク処理を任せることで、Cloud Functionsに頼らずとも柔軟な運用が可能になります。