Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Gocardless Pro Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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.

Gotchas and Tips

  • The '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.
  • Amounts are integers in minor units. 19.99 becomes 1999. Never store or pass floats; round at the boundary explicitly.
  • Responses are magic-getter objects, not arrays. $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.
  • No built-in retry. A RateLimitException (429) carries Retry-After; wrap calls in $this->release($seconds) retries in jobs using exponential backoff, and reuse the same idempotency key.
  • Always log the request id. $e->getRequestId() (also on ApiException) is the only thing GoCardless support will ask for.
  • Redirect flows are single-use and short-lived. Complete them exactly once; a double-submit from a refreshed return URL throws. Guard the callback with a DB unique constraint on redirect_flow_id.
  • Mandates are not instantly usable. A new mandate can sit in 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.
  • Webhook signature is over the raw body. Any middleware that re-reads or re-encodes the payload breaks HMAC. Also verify against your endpoint secret, not the API access token — different value, and a common paste error.
  • This is generated code. Don't patch 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.
  • Extension point: if you need tracing, subclass \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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle