graham-campbell/bitbucket
A Laravel/PHP package by Graham Campbell that provides a clean, configurable Bitbucket Cloud API v2 client, with service provider and facade integration for working with repositories, pull requests, users, webhooks, and related resources.
Install and publish.
composer require graham-campbell/bitbucket
php artisan vendor:publish --provider="GrahamCampbell\Bitbucket\BitbucketServiceProvider"
Auto-discovery registers the service provider and the Bitbucket facade alias; the publish step drops config/bitbucket.php into place. If you are on a pre-auto-discovery Laravel version (you should not be, given the v11.1.0 matrix), register provider and alias manually.
Configure one connection. The config defines a default connection plus a connections map. Pick your auth mode per connection:
driver = app password, with the Bitbucket username and the generated app password. Not your account password, and not your email — Bitbucket app passwords pair with the username.token and refresh_token.Drive it from env (BITBUCKET_DRIVER, BITBUCKET_USERNAME, BITBUCKET_PASSWORD, BITBUCKET_CLIENT_ID, BITBUCKET_CLIENT_SECRET, BITBUCKET_TOKEN, …) so config:cache works unchanged in production.
First use case: prove the plumbing with a read-only command. Write one artisan command that lists open pull requests for a repository and prints id/title/author:
$client = Bitbucket::connection(); // default connection from config
$prs = $client->pullRequests()->list($workspace, $repo, ['state' => 'OPEN']);
foreach ($prs as $pr) {
$this->line("#{$pr['id']} {$pr['title']}");
}
That single command validates credentials, network/egress, scopes, and pagination handling at once — which is exactly the set of things that fails first. Once it works against a non-production workspace, you have your template for every other endpoint.
Where to look first. The facade resolves to the package's manager; Bitbucket::connection('name') hands you an instance of Bitbucket\Client from bitbucket/client. The package itself is thin — the surface you will actually live in is the SDK's resource classes (repositories(), pullRequests(), issues(), pipelines(), webhooks()), so keep the SDK docs open and let your IDE inspect the resource objects rather than guessing method signatures.
Wrap the SDK behind your own interface — always. This is the single highest-leverage decision you make on day one. Application code should never import Bitbucket\Client directly; the next bitbucket/client major then costs you one class, not a codebase sweep.
interface BitbucketGateway
{
public function openPullRequests(string $workspace, string $repo): array;
public function postBuildStatus(string $workspace, string $repo, string $sha, string $state): void;
}
final class SdkBitbucketGateway implements BitbucketGateway
{
public function __construct(private \Bitbucket\Client $client) {}
public function openPullRequests(string $workspace, string $repo): array
{
$out = [];
foreach ($this->client->pullRequests()->list($workspace, $repo, ['state' => 'OPEN']) as $pr) {
$out[] = ['id' => $pr['id'], 'title' => $pr['title'], 'author' => $pr['author']['display_name'] ?? null];
}
return $out;
}
}
// AppServiceProvider::register()
$this->app->singleton(BitbucketGateway::class, fn ($app) =>
new SdkBitbucketGateway($app->make(\GrahamCampbell\Bitbucket\BitbucketManager::class)->connection())
);
Controllers, jobs, and commands type-hint BitbucketGateway. Swapping to a raw-SDK implementation, a fake, or a different provider becomes a container binding.
Push all writes and bulk reads onto the queue. Bitbucket's rate limits and pagination are the throughput ceiling, not the package. Any operation that enumerates repos, PRs, or commits should be a job with explicit retry/backoff, not a synchronous call in a request:
final class SyncPullRequests implements ShouldQueue
{
public int $tries = 5;
public array $backoff = [10, 60, 300, 900];
public int $timeout = 120;
public function handle(BitbucketGateway $gateway): void { /* … */ }
}
Persist a cursor (last synced timestamp or page token) so a failed job resumes rather than restarts.
Treat pagination as a first-class concern. The SDK's list calls return paginated collections. Iterate them; do not index into a single response and assume you got everything. If you need bounded work, paginate deliberately and record the next cursor. A dashboard that shows "the first 10 PRs" is a bug report waiting to happen on a busy repo.
Verify webhooks yourself. The package is an outbound API client; inbound webhook verification is your code. Bitbucket signs payloads with X-Hub-Signature (HMAC-SHA256 over the raw body):
$raw = $request->getContent();
$expected = 'sha256='.hash_hmac('sha256', $raw, config('services.bitbucket.webhook_secret'));
abort_unless(hash_equals($expected, (string) $request->header('X-Hub-Signature')), 403);
ProcessBitbucketEvent::dispatch($request->header('X-Event-Key'), $raw);
Verify against the raw body, not $request->all() re-encoded — JSON re-encoding breaks the digest. Then hand off to a job and return 202 immediately; Bitbucket will retry on non-2xx and you do not want a slow handler causing duplicate deliveries.
Cache reads, not permissions. Repo lists, workspace members, and branch lists change slowly and are read on every page load of a dashboard. Cache::remember("bb:repos:{$workspace}", 300, fn () => ...) cuts your API calls by an order of magnitude. Never cache the result of a permission-dependent call without keying the cache by the acting identity — a cached PR list from an admin token served to a restricted user is a data-leak bug, not a performance win.
Use named connections for distinct identities. A bot connection for automation and a user-delegated OAuth connection for interactive features. Bitbucket::connection('bot') and Bitbucket::connection('user') keep scopes and rate-limit budgets separated.
Fake the gateway in tests, not HTTP. Because the SDK uses its own HTTP client, Http::fake() will not intercept calls. Mock your BitbucketGateway binding in feature tests. For genuine SDK-level contract tests, inject a Guzzle client with a MockHandler into the SDK instance — but those belong in a small, separate suite that runs against recorded fixtures, not in the main test path.
Http::fake() silently does nothing here. The SDK speaks Guzzle directly, so Laravel's HTTP client fakes are bypassed and your "unit" test makes real network calls. This is the most common false-positive in a first implementation. Mock your gateway interface instead.Cache::lock).Retry-After on 429. Catch rate-limit responses explicitly, honour the header, and let job backoff do the rest. Retrying immediately turns a throttle into an outage. Instrument 429 rate as a first-class metric — exception volume alone hides it.config:cache is a plaintext secret at rest. The published config is env-driven, which is correct, but remember the cached file contains the resolved consumer secret and app password. Restrict file permissions, keep it out of image layers you publish, and php artisan queue:restart after any credential change — long-running workers hold the old config in memory.timeout and connect_timeout in the connection config, and never call the API synchronously from a web request without them.bitbucket/client version floor moves on minor wrapper releases. v11.1.0 requires ^5.1 on what is nominally a minor bump. If anything else in your tree pins the SDK to 4.x, composer update will either fail or silently re-resolve. Run composer why-not bitbucket/client ^5.1 before scheduling the upgrade, and check your call sites for catch blocks and type hints against SDK exception classes and pagination iterators — those are what a 4→5 bump actually breaks.Bitbucket::connection() returns a fresh-looking object but connections are cached by the manager, so any state you mutate on the client (default headers, custom middleware) leaks across callers in the same process. Prefer per-call configuration over mutating the shared client.Bitbucket::connection() calls inside a loop are free but not harmless — they are the same instance, so concurrent-ish state (last request id, injected auth token) is shared. In queue workers this is usually fine; in Octane it is a genuine cross-request bleed risk. If you run Octane, resolve the client per request or reset it in a middleware.Bitbucket::connection() is essentially new Bitbucket\Client() plus auth, so a fork or an in-house replacement is an afternoon, not a project. Pin the version in your dependency register and review it on the same annual cadence as your framework upgrades.How can I help you explore Laravel packages today?