Instagram Graph APIで画像を投稿する機能に、「予約投稿」と「失敗時の再試行」を足したときの構成メモです。投稿の状態をstatusカラムで管理し、各操作をその遷移として整理しています。
Instagramの画像投稿は、作る→待つ→公開の3ステップ
Instagram APIの画像投稿は、1回のリクエストでは終わりません。
-
/{ig-user-id}/mediaで「コンテナ」(投稿の下書き)を作る →creation_idが返る -
/{creation_id}?fields=status_codeで、コンテナの処理がFINISHEDになるまで待つ -
/{ig-user-id}/media_publishにcreation_idを渡して公開する → 投稿のmedia_idが返る
2番目の「待つ」は、最初の版にはありませんでした。コンテナを作った直後に公開を呼ぶと、画像の取り込みが終わっておらず、Media ID is not availableで失敗することがあるためです。
// app/Services/InstagramPublishingService.php
$container = Http::asForm()->post(self::API_BASE."/{$account->instagram_user_id}/media", [
'image_url' => $imageUrl,
'caption' => $caption,
'access_token' => $accessToken,
])->throw()->json();
$creationId = $container['id'] ?? throw new InstagramPublishException('コンテナ作成のレスポンスが不正です。', 'unknown');
// 画像の取り込みが終わる前にmedia_publishを呼ぶと「Media ID is not available」で失敗するため、先に完了を待つ
$this->waitUntilContainerIsReady($creationId, $accessToken);
$published = Http::asForm()->post(self::API_BASE."/{$account->instagram_user_id}/media_publish", [
'creation_id' => $creationId,
'access_token' => $accessToken,
])->throw()->json();
image_urlには、Instagram側のサーバーがアクセスできる公開URLを渡す必要があります。ローカルのファイルパスは使えないので、アップロード画像はpublicディスクに保存して、そのURLを渡しています。
待つ処理は、status_codeがFINISHEDなら公開へ進み、ERROR/EXPIREDなら公開せず失敗にします。状況が取れない、または既定の回数(15回、2秒おき)で終わらない場合は、そのまま公開を試します。
APIの呼び出しだけを担当するこのクラスは、成功したらmedia_idを返し、失敗したら独自の例外(エラー種別つき)を投げます。投稿の状態管理は、その外側のサービスに分けています。
状態は6つ
$table->enum('status', ['draft', 'scheduled', 'processing', 'published', 'failed', 'cancelled'])->default('draft');
操作ごとに、どの状態からどの状態へ動くかを整理するとこうなります。
| 操作 | 遷移 |
|---|---|
| 今すぐ投稿 | 保存時にprocessing → 成功ならpublished、失敗ならfailed
|
| 予約 |
scheduledで保存 |
| 予約時刻の到来(cron) |
scheduled → processing → published / failed
|
| 再試行 |
failed → processing → published / failed
|
| 取り消し | cancelled |
サービスクラスでは、これがそのままメソッドになっています。
public function publishNow(Customer $customer, UploadedFile $image, ?string $caption): SocialPost
{
$post = $this->store($customer, $image, $caption, null, 'processing');
$this->attemptPublish($post, $customer);
return $post->fresh();
}
public function schedule(Customer $customer, UploadedFile $image, ?string $caption, DateTimeInterface $scheduledAt): SocialPost
{
return $this->store($customer, $image, $caption, $scheduledAt, 'scheduled');
}
public function publishScheduled(SocialPost $post, Customer $customer): SocialPost
{
$post->update(['status' => 'processing']);
$this->attemptPublish($post, $customer);
return $post->fresh();
}
public function retry(SocialPost $post, Customer $customer): SocialPost
{
$post->update(['status' => 'processing', 'error_message' => null]);
$this->attemptPublish($post, $customer);
return $post->fresh();
}
4つのメソッドは、最初にstatusをセットして、すべて同じattemptPublish()に合流します。公開処理の本体が1か所なので、結果の書き込み(成功・失敗)が食い違いません。retry()だけは前回のエラーメッセージを消してから再実行します。
結果の書き込みは1か所
private function attemptPublish(SocialPost $post, Customer $customer): void
{
$imageUrl = Storage::disk('public')->url($post->media_path);
try {
$result = $this->publishingService->publishImage($customer, $imageUrl, $post->caption);
$post->update([
'status' => 'published',
'published_at' => now(),
'provider_media_id' => $result['media_id'],
]);
} catch (InstagramPublishException $e) {
$post->update([
'status' => 'failed',
'error_message' => $e->getMessage(),
]);
if ($e->isAuthExpired()) {
$customer->instagramAccount?->update(['status' => 'expired']);
}
}
}
失敗時には、エラーの種別が「認証切れ」だった場合に限り、接続しているInstagramアカウント側のstatusもexpiredに更新します。アカウント接続の状態を実態に合わせておくためです。トークンが切れているのに「接続済み」のままだと、次の投稿も同じ理由で失敗し続けます。投稿の失敗が、アカウント接続の状態にも反映される作りです。
予約時刻の到来はcronで拾う
// app/Console/Commands/PublishDueInstagramPosts.php
$duePosts = SocialPost::with('customer')
->where('provider', 'instagram')
->where('status', 'scheduled')
->where('scheduled_at', '<=', now())
->get();
foreach ($duePosts as $post) {
$post = $instagramPostService->publishScheduled($post, $post->customer);
// 結果をログに出す
}
// routes/console.php
Schedule::command('instagram:publish-due-posts')->everyTwoMinutes();
status = 'scheduled'かつscheduled_at <= now()で拾い、publishScheduled()の最初にprocessingへ更新します。processingになった投稿は次回のcronの検索条件から外れるので、2分おきの実行が来ても同じ投稿を再度拾うことはありません。
ここで、正直に書いておくべき点があります。この構成ではwithoutOverlapping()を付けていません。1回の実行が2分を超えるほど予約が溜まると、次の実行と重なる可能性があります。予約が少ないうちは問題になりませんが、件数が増える場合は、スケジュールに->withoutOverlapping()を足すか、取得と同時にprocessingへ更新する処理(行ロックなど)を検討する余地があります。
まとめ
- 画像投稿は「コンテナ作成→処理完了待ち→公開」の3ステップで、画像は公開URLで渡す
- 投稿の状態を
draft / scheduled / processing / published / failed / cancelledで持ち、操作ごとの遷移を表にしてから実装する - 今すぐ・予約・再試行がすべて同じ
attemptPublish()に合流するので、結果の書き込みは1か所で済む - トークン失効のエラーは、投稿の失敗だけでなくアカウント接続の状態にも反映する
- cronを短い間隔で回す場合は、重複実行への備えを先に決めておく
この記事はZennにも投稿しています。