Some third-party APIs don’t give you an instant answer. You send a request, they return "status": "processing", and you’re expected to poll until the result is ready. Payment gateways do this a lot β especially for bank transfers and manual review flows.
Here’s the pattern that’s worked well for handling this in Laravel.
The Problem
Your controller sends a request to an external API. Instead of a final result, you get:
{
"transaction_id": "txn_abc123",
"status": "processing",
"estimated_completion": "30s"
}
You can’t block the HTTP request for 30 seconds. But you also can’t just ignore it β your workflow depends on the result.
The Solution: Dispatch a Polling Job
// In your service
public function initiatePayment(Order $order): void
{
$response = Http::post('https://api.provider.com/charge', [
'amount' => $order->total,
'reference' => $order->reference,
]);
if ($response->json('status') === 'processing') {
PollPaymentStatus::dispatch(
transactionId: $response->json('transaction_id'),
orderId: $order->id,
attempts: 0,
)->delay(now()->addSeconds(10));
}
}
The Polling Job
class PollPaymentStatus implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable;
private const MAX_ATTEMPTS = 10;
private const POLL_INTERVAL = 15; // seconds
public function __construct(
private readonly string $transactionId,
private readonly int $orderId,
private readonly int $attempts,
) {}
public function handle(): void
{
$response = Http::get(
"https://api.provider.com/status/{$this->transactionId}"
);
$status = $response->json('status');
if ($status === 'completed') {
$this->onSuccess($response->json());
return;
}
if ($status === 'failed') {
$this->onFailure($response->json('error'));
return;
}
// Still processing β re-dispatch with backoff
if ($this->attempts >= self::MAX_ATTEMPTS) {
$this->onTimeout();
return;
}
self::dispatch(
$this->transactionId,
$this->orderId,
$this->attempts + 1,
)->delay(now()->addSeconds(self::POLL_INTERVAL));
}
private function onSuccess(array $data): void
{
$order = Order::find($this->orderId);
$order->markAsPaid($data['reference']);
// Continue your workflow...
}
private function onFailure(string $error): void
{
Log::error("Payment failed: {$error}", [
'transaction_id' => $this->transactionId,
]);
}
private function onTimeout(): void
{
Log::warning("Payment polling timed out after " . self::MAX_ATTEMPTS . " attempts");
}
}
Why This Works
The job re-dispatches itself with a delay, creating a non-blocking polling loop. Your queue worker handles the timing. Your controller returns immediately. And you get clean callback methods (onSuccess, onFailure, onTimeout) for each outcome.
The key insight: the job IS the polling loop. Each dispatch is one iteration. The delay between dispatches is your poll interval. And the max attempts give you a clean exit.

Leave a Reply