0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

はじめに

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
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はこちらからアクセスできます。

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

ステップ2: ステップ2: LaravelでFirestoreクライアントを設定

guzzlehttp/guzzleをプロジェクトに追加します。

composer require guzzlehttp/guzzle

app/Services/FirestoreService.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)にアクセスして処理を行うコードを実装します。

php artisan make:command ProcessFirestoreTasks
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: スケジューラーに登録

実装した処理をタスクスケジューラーに追加します。

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に頼らずとも柔軟な運用が可能になります。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?