gocardless/gocardless-pro
Official PHP client library for the GoCardless Pro API. Install via Composer to create and manage customers, mandates, subscriptions, and payments, with simple, PSR-compatible access to direct debit and recurring payment endpoints.
Install and configure.
composer require gocardless/gocardless-pro
// config/services.php
'gocardless' => [
'token' => env('GOCARDLESS_ACCESS_TOKEN'),
'environment' => env('GOCARDLESS_ENVIRONMENT', 'sandbox'),
'webhook_secret' => env('GOCARDLESS_WEBHOOK_SECRET'),
],
The package ships no Laravel service provider, so bind it yourself once and inject Client everywhere:
// app/Providers/AppServiceProvider.php
$this->app->singleton(\GoCardlessPro\Client::class, fn () => new \GoCardlessPro\Client([
'access_token' => config('services.gocardless.token'),
'environment' => config('services.gocardless.environment') === 'live'
? \GoCardlessPro\Environment::LIVE
: \GoCardlessPro\Environment::SANDBOX,
]));
Where to look first (in vendor/gocardless/gocardless-pro/lib, not the README):
| Path | Why |
|---|---|
Client.php |
constructor options, getHttpClient(), magic __call into services |
Services/*Service.php |
the actual accepted options per endpoint (source of truth over docs) |
Resources/*.php |
field names on returned objects ($payment->id, $mandate->status) |
Core/Exception/*.php |
exception hierarchy you'll map to HTTP responses |
Webhook.php |
signature verification helper |
First use case — take a one-off payment via redirect flow. Every GoCardless integration is the same three-legged dance: redirect flow → mandate → payment.
$flow = $client->redirectFlows()->create(['params' => [
'description' => 'Invoice #1042',
'session_token' => $sessionToken = Str::uuid()->toString(),
'success_redirect_url' => route('gocardless.return'),
'prefilled_customer' => ['email' => $user->email, 'given_name' => $user->first_name],
]]);
session(['gc_flow' => $flow->id, 'gc_token' => $sessionToken]);
return redirect($flow->redirect_url);
On return, complete the flow and store the mandate:
$completed = $client->redirectFlows()->complete($request->query('redirect_flow_id'), [
'params' => ['session_token' => session('gc_token')],
]);
$mandateId = $completed->links->mandate;
Then create the payment against the mandate once it is active. That's the whole loop — everything else (subscriptions, customers, payouts, refunds) is a variation.
Wrap the client in a thin repository per aggregate. The generated services take raw payload arrays; don't let params nesting leak into controllers.
class Payments
{
public function __construct(private \GoCardlessPro\Client $client) {}
public function charge(string $mandateId, int $pence, string $ref): \GoCardlessPro\Resources\Payment
{
return $this->client->payments()->create([
'params' => [
'amount' => $pence, // minor units, integer
'currency' => 'GBP',
'links' => ['mandate' => $mandateId],
'reference' => $ref,
'metadata' => ['invoice_id' => (string) $ref],
],
'headers' => ['Idempotency-Key' => "invoice-{$ref}-attempt-1"],
]);
}
}
Always send an Idempotency-Key on POST. Network timeouts happen after GoCardless accepted the payment; without the key a retry double-charges. Derive it deterministically from your domain object, not Str::uuid(), so a queued retry reuses it.
Pagination is cursor-based. list() returns one page; all() is a lazy generator that walks every page for you:
foreach ($client->payments()->all(['params' => ['limit' => 200, 'created_at[gte]' => '2025-01-01']]) as $payment) {
// ...
}
Use all() for reconciliation jobs, list() + after only when you need to persist a resume cursor.
Webhooks: verify on the raw body, then queue.
Route::post('/webhooks/gocardless', WebhookController::class)
->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);
// controller
$webhook = \GoCardlessPro\Webhook::parse(
$request->getContent(), // raw body, NOT $request->all()
$request->header('Webhook-Signature'),
config('services.gocardless.webhook_secret')
);
foreach ($webhook->events as $event) {
ProcessGoCardlessEvent::dispatch($event->id, $event->resource_type, $event->links->{$event->resource_type});
}
return response()->noContent();
Persist $event->id with a unique index — GoCardless retries deliveries and does not guarantee ordering. Never do business logic inline; ack in <2s or they'll retry.
Map API errors to user-facing feedback. The library throws typed exceptions:
use GoCardlessPro\Core\Exception\{ValidationException, InvalidApiUsageException, RateLimitException, GoCardlessException};
try {
$this->payments->charge(...);
} catch (ValidationException $e) {
// 422 — $e->getErrors() is an array of ['field' => ..., 'message' => ...]
throw ValidationException::withMessages(collect($e->getErrors())->pluck('message', 'field')->all());
} catch (InvalidApiUsageException $e) {
report($e); // usually *your* bug: wrong nesting, bad currency, missing mandate
abort(500);
}
Test with Guzzle MockHandler, not Laravel's Http::fake(). The client uses its own Guzzle stack, so Http::fake() never intercepts. Inject a stub client instead:
$mock = new \GuzzleHttp\Handler\MockHandler([new \GuzzleHttp\Psr7\Response(201, [], json_encode($fixture))]);
$client = new \GoCardlessPro\Client([
'access_token' => 'test',
'environment' => \GoCardlessPro\Environment::SANDBOX,
'http_client' => new \GuzzleHttp\Client(['handler' => \GuzzleHttp\HandlerStack::create($mock)]),
]);
$this->app->instance(\GoCardlessPro\Client::class, $client);
Record real sandbox responses once and replay them as fixtures — the resource classes are just typed wrappers, so fixtures stay valid across minor versions.
Sandbox specifics for end-to-end testing: sort code 200000, account 55779911 is the classic "succeeds immediately" test account; 55779911 variants simulate failure and pending_customer_approval states. Keep the sandbox and live tokens as separate env entries and never derive the environment from APP_ENV implicitly.
'params' => wrapper is mandatory on create/update and is the #1 source of invalid_api_usage 400s. A missing wrapper looks like an empty body to the API. Nested objects are plain arrays — no new Customer(...).headers goes in the same options array as params, not as a second argument. create(['params' => [...], 'headers' => ['Idempotency-Key' => '...']]).list()/get() option shape differs from create(). Read the specific service file rather than assuming; filters go flat ('created_at[gte]') while some versions still expect params.19.99 becomes 1999. Never store or pass floats; round at the boundary explicitly.$payment->id works; $payment['id'] and json_encode($payment) may not do what you expect. Call ->toArray() (or store the raw api_response) when persisting.RateLimitException (429) carries Retry-After; wrap calls in $this->release($seconds) retries in jobs using exponential backoff, and reuse the same idempotency key.$e->getRequestId() (also on ApiException) is the only thing GoCardless support will ask for.redirect_flow_id.pending_customer_approval; gate payment creation on status === 'active' and handle failed/cancelled as terminal.charge_date must be a bank business day. Weekend/holiday dates get silently pushed or rejected depending on scheme. Default to "earliest possible" (omit it) unless you specifically need a scheduled collection.vendor/; pin an exact version in composer.json and re-run your fixture-based test suite on upgrade, since resource classes can gain fields between minors.\GoCardlessPro\Client or swap the Guzzle middleware via the http_client option — that's the only hook the library exposes. Everything else (logging, metrics, circuit breaking) belongs in your repository wrapper.How can I help you explore Laravel packages today?