#20 FilamentPHP応用

リソースのAPI連携——外部APIデータをFilamentリソースとして表示

外部APIデータをFilamentで管理する

すべてのデータがローカルDBに存在するわけではありません。外部API(Stripe・GitHub・Shopify・社内APIなど)から取得したデータをFilamentの管理画面で扱いたいケースがあります。

このエピソードでは、Eloquentモデルなしで外部APIデータをFilamentリソースとして表示・操作する方法を解説します。

アプローチ1:APIデータをEloquentモデルにキャッシュ

最もシンプルな方法は外部APIのデータを定期的にDBに同期し、Eloquentモデルを通して扱うことです。

// app/Models/StripeCustomer.php
class StripeCustomer extends Model
{
    protected $fillable = [
        'stripe_id', 'name', 'email', 'currency',
        'balance', 'created_at_stripe',
    ];

    protected $casts = [
        'balance' => 'integer',
        'created_at_stripe' => 'datetime',
    ];
}
// app/Console/Commands/SyncStripeCustomers.php
class SyncStripeCustomers extends Command
{
    protected $signature = 'stripe:sync-customers';

    public function handle(): void
    {
        $stripe = new \Stripe\StripeClient(config('services.stripe.secret'));

        $customers = $stripe->customers->all(['limit' => 100]);

        foreach ($customers->data as $customer) {
            StripeCustomer::updateOrCreate(
                ['stripe_id' => $customer->id],
                [
                    'name'              => $customer->name,
                    'email'             => $customer->email,
                    'currency'          => $customer->currency,
                    'balance'           => $customer->balance,
                    'created_at_stripe' => Carbon::createFromTimestamp($customer->created),
                ]
            );
        }

        $this->info('Stripe顧客データを同期しました。');
    }
}

このアプローチはシンプルで検索・ソート・フィルタがすべてEloquentで動作するメリットがあります。

アプローチ2:APIデータをリアルタイムに表示

DBキャッシュを使わず、APIデータを直接取得して表示するカスタムページを作る方法です。

// app/Filament/Pages/StripeCustomers.php

namespace App\Filament\Pages;

use Filament\Pages\Page;
use Illuminate\Support\Facades\Http;

class StripeCustomers extends Page
{
    protected static string $view = 'filament.pages.stripe-customers';
    protected static ?string $navigationIcon = 'heroicon-o-credit-card';
    protected static ?string $navigationLabel = 'Stripe顧客';

    public function getCustomers(): array
    {
        return cache()->remember('stripe_customers', 300, function (): array {
            $response = Http::withToken(config('services.stripe.secret'))
                ->get('https://api.stripe.com/v1/customers', [
                    'limit' => 100,
                ]);

            if ($response->failed()) {
                return [];
            }

            return $response->json('data', []);
        });
    }

    protected function getViewData(): array
    {
        return [
            'customers' => $this->getCustomers(),
        ];
    }
}

アプローチ3:仮想Eloquentモデルでフル機能を活かす

EloquentのAPI(ページネーション・ソート・フィルタ)をAPIデータにも使いたい場合、カスタムEloquentモデルとリポジトリパターンを組み合わせます。

// app/Models/GitHubRepository.php

class GitHubRepository extends Model
{
    // DBテーブルは実際には不使用(テーブル名を存在しない名前に)
    protected $table = 'github_repositories_virtual';

    protected $fillable = [
        'id', 'full_name', 'description', 'language',
        'stargazers_count', 'forks_count', 'open_issues_count',
        'html_url', 'updated_at',
    ];
}

ただし、このアプローチはEloquentのDBクエリが走るため実際には使いにくく、カスタムページのほうが実用的です。

カスタムTableコンポーネントを使ったAPIデータ表示

最も現実的なのは、FilamentのTableを直接使いつつデータをAPIから取得するパターンです。

namespace App\Filament\Pages;

use Filament\Pages\Page;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Filament\Tables\Columns\TextColumn;
use Illuminate\Support\Collection;

class GitHubIssues extends Page implements HasTable
{
    use InteractsWithTable;

    protected static string $view = 'filament.pages.github-issues';
    protected static ?string $navigationIcon = 'heroicon-o-bug-ant';
    protected static ?string $navigationLabel = 'GitHubイシュー';

    public function table(Table $table): Table
    {
        return $table
            ->query(fn () => $this->getIssuesQuery())
            ->columns([
                TextColumn::make('number')
                    ->label('#')
                    ->sortable(),

                TextColumn::make('title')
                    ->label('タイトル')
                    ->searchable()
                    ->limit(60)
                    ->url(fn ($record) => $record->html_url),

                TextColumn::make('user.login')
                    ->label('作成者'),

                TextColumn::make('state')
                    ->label('状態')
                    ->badge()
                    ->color(fn (string $state): string =>
                        $state === 'open' ? 'success' : 'gray'
                    ),

                TextColumn::make('created_at')
                    ->label('作成日')
                    ->since(),
            ])
            ->filters([
                \Filament\Tables\Filters\SelectFilter::make('state')
                    ->options(['open' => 'Open', 'closed' => 'Closed']),
            ]);
    }

    private function getIssuesQuery(): Builder
    {
        // APIから取得したデータをコレクションに変換
        // 実際にはAPIデータをDBにキャッシュしたモデルを使う
        return GitHubIssueCache::query();
    }
}

HTTPクライアントのレート制限対応

外部APIには多くの場合レート制限があります。

class GitHubApiService
{
    private Http $client;

    public function __construct()
    {
        $this->client = Http::withToken(config('services.github.token'))
            ->baseUrl('https://api.github.com')
            ->acceptJson()
            ->retry(3, 100, fn (\Exception $exception): bool =>
                $exception instanceof \Illuminate\Http\Client\ConnectionException
            );
    }

    public function getIssues(string $repo, array $params = []): array
    {
        $cacheKey = "github_issues_{$repo}_" . md5(serialize($params));

        return cache()->remember($cacheKey, 600, function () use ($repo, $params): array {
            $response = $this->client->get("/repos/{$repo}/issues", $params);

            if ($response->tooManyRequests()) {
                // レート制限に引っかかった場合はリトライ待機
                $retryAfter = $response->header('Retry-After', 60);
                throw new \RuntimeException("GitHub API rate limit exceeded. Retry after {$retryAfter}s");
            }

            return $response->json() ?? [];
        });
    }
}

アクションでAPI操作を実行

APIの書き込み操作もFilamentのActionとして実装できます。

Action::make('closeIssue')
    ->label('イシューをクローズ')
    ->icon('heroicon-o-x-circle')
    ->color('danger')
    ->requiresConfirmation()
    ->action(function (array $record): void {
        $response = Http::withToken(config('services.github.token'))
            ->patch("https://api.github.com/repos/{$record['repo']}/issues/{$record['number']}", [
                'state' => 'closed',
            ]);

        if ($response->failed()) {
            Notification::make()
                ->title('クローズに失敗しました')
                ->danger()
                ->send();
            return;
        }

        cache()->forget("github_issues_{$record['repo']}*");

        Notification::make()
            ->title('イシューをクローズしました')
            ->success()
            ->send();
    })

コツ・注意点・ハマりポイント

コツ: 外部APIデータをFilamentで管理する場合、「DBキャッシュ同期方式」が最も機能的です。Eloquentのフル機能(検索・ソート・フィルタ・エクスポート)がそのまま使えます。Cronで定期同期し、webhookで差分更新する構成が理想的です。

注意点: APIデータをリアルタイム表示する場合、テーブルのページネーションやソートがデフォルトではAPIに対応しません。カスタムページとInteractsWithTableトレイトを使う場合でも、クエリはEloquentベースである必要があります。

ハマりポイント: HTTPクライアントでAPIキーを->withToken()->withBasicAuth()で設定するのを忘れて、認証エラーが出てもエラーメッセージが分かりにくいことがあります。$response->status()$response->json()でデバッグしてください。

まとめ

外部APIデータをFilamentで扱うには「DBキャッシュ同期」が最も実用的なアプローチです。Cronでの定期同期とwebhookによる差分更新を組み合わせることで、EloquentのフルパワーをAPIデータにも適用できます。リアルタイム性が必要なケースではInteractsWithTableを使ったカスタムページが有力な選択肢です。