hprotravel/symfony-gcp-rest-bundle
A Symfony bundle from hprotravel that integrates Google Cloud Platform REST services into Symfony applications, providing framework configuration and service wiring for simpler GCP REST API setup and use.
Day one is extraction, not installation. This is a Symfony bundle; Laravel has no bundle system, so composer require alone gets you autoloaded classes that Laravel will never instantiate. The realistic first step is to clone the repo, read it, and decide what to keep.
1. Get access and pin a commit. Add a VCS repository entry with a credentials callback for Bitbucket — do not use dev-master.
{
"repositories": [{
"type": "vcs",
"url": "[email protected]:hprotravel/symfony-gcp-rest-bundle.git",
"no-api": true
}],
"require": {
"hprotravel/symfony-gcp-rest-bundle": "dev-master#<pinned-sha>"
}
}
2. Read these files first, in this order. If the file doesn't exist, that's a signal:
composer.json — is symfony/framework-bundle a hard require? If so, the extraction cost just went up sharply.src/DependencyInjection/Configuration.php — this is the real API surface; it tells you every config key (project_id, credentials_path, scopes, endpoints, cache, etc.).src/DependencyInjection/*Extension.php — shows how the config tree maps to service arguments.src/Resources/config/services.xml or services.yaml — the actual class list you'll be re-wiring.*Client, *Authenticator, *TokenProvider — the framework-agnostic core.3. Register a Laravel service provider. You do not register the bundle. You register a GcpRestServiceProvider that binds each GCP client as a singleton, reading from config/gcp.php.
// config/gcp.php
return [
'project_id' => env('GCP_PROJECT_ID'),
'credentials_path' => env('GCP_CREDENTIALS_PATH'), // or base64 JSON in env
'scopes' => [],
'endpoints' => [
'storage' => 'https://storage.googleapis.com/storage/v1',
'pubsub_publish' => 'https://pubsub.googleapis.com/v1',
'secret_manager' => 'https://secretmanager.googleapis.com/v1',
],
];
final class GcpRestServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(__DIR__.'/../config/gcp.php', 'gcp');
$this->app->singleton(TokenProvider::class, fn ($app) =>
new ServiceAccountTokenProvider(
credentialsPath: $app['config']['gcp.credentials_path'],
scopes: $app['config']['gcp.scopes'],
cache: $app->make(CacheRepository::class), // PSR-6
)
);
$this->app->singleton(\HProTravel\SymfonyGcpRestBundle\Client\GcpClient::class, function ($app) {
return new \HProTravel\SymfonyGcpRestBundle\Client\GcpClient(
projectId: $app['config']['gcp.project_id'],
tokenProvider: $app->make(TokenProvider::class),
);
});
}
}
4. First use case — proving the plumbing. Before touching Pub/Sub or Storage, exercise a single authenticated GET. A Secret Manager projects/{p}/secrets/{s}/versions/latest:access call is the cheapest way to verify: credentials load, JWT signs, token is fetched/cached, HTTP call succeeds, response decodes. Wrap it in a php artisan tinker line; if that returns a secret, the rest is just endpoint plumbing.
$client = app(\HProTravel\SymfonyGcpRestBundle\Client\GcpClient::class);
$body = $client->authGet('https://secretmanager.googleapis.com/v1/projects/'.config('gcp.project_id').'/secrets/my-secret/versions/latest:access');
If the file structure is very different from this shape, stop and reassess — the value in this package is the auth/token layer and the config mapping; if those are entangled with Symfony internals, you're writing it yourself and should just use google/auth + Http::withToken().
Wrap the package behind your own interface, always. You will either fork, vendor, or replace this. Make the swap a one-class change.
interface SecretManager
{
public function get(string $name): string;
}
final class GcpSecretManager implements SecretManager
{
public function __construct(private readonly GcpRestClient $client) {}
public function get(string $name): string
{
$json = $this->client->authGet(sprintf(
'https://secretmanager.googleapis.com/v1/projects/%s/secrets/%s/versions/latest:access',
config('gcp.project_id'), $name,
));
return base64_decode($json['payload']['data']);
}
}
Bind it in your provider, inject SecretManager everywhere. When the package rots, you rewrite GcpSecretManager and nothing else.
One client per GCP surface, as singletons. The package's Symfony service definitions almost certainly do this per config block; mirror it. Per-request instantiation would defeat connection reuse and the token cache.
Resolve credentials once at boot, not per call. Whether the package uses Credentials::fromFile() or something custom, hoist it. Per-call service-account parsing is measurable.
$this->app->singleton(Credentials::class, function () {
return Credentials::fromFile(config('gcp.credentials_path'));
});
Use Http::withToken() in the extracted client, not the bundle's HTTP plumbing if it's Symfony-bound. Laravel's HTTP client gives you retries, timeouts, and ->throw() for free. The bundle's job is only to acquire the token.
final class GcpRestClient
{
public function __construct(
private readonly TokenProvider $tokens,
private readonly string $projectId,
) {}
public function authGet(string $url): array
{
return Http::withToken($this->tokens->accessToken())
->timeout(10)
->retry(3, 200, throw: false)
->get($url)
->throw()
->json();
}
}
Token caching: shared store or nothing. The day-to-day performance win from this package is that the token isn't re-signed per request. If you extract the auth layer, preserve the PSR-6 cache hookup and back it with Redis.
final class ServiceAccountTokenProvider implements TokenProvider
{
public function __construct(
private readonly Credentials $creds,
private readonly CacheRepository $cache,
) {}
public function accessToken(): string
{
return Cache::remember('gcp:token:'.$this->cacheKey(), 3300, function () {
$token = $this->creds->fetchAuthToken();
return $token['access_token'];
});
}
}
Config mapping is manual. The bundle's Configuration.php is a Symfony tree; you translate each node to a config/gcp.php key by hand. Keep key names identical where possible — future diffs against the upstream bundle then read cleanly.
Feature-flag per GCP surface. The bundle is likely to cover only a few endpoints. Expose a config array of enabled services and bind them conditionally so a service you don't use can't fail discovery.
Contract tests against a real GCP sandbox project. This is the whole point. Mocking token issuance hides the bugs you care about (scope, clock skew, key rotation). One integration test per surface, guarded by an env var:
public function test_secret_manager_roundtrip(): void
{
if (!env('GCP_INTEGRATION')) $this->markTestSkipped();
$this->assertNotEmpty(app(SecretManager::class)->get('test-secret'));
}
The bundle may not be auto-discoverable. Symfony Flex only auto-registers well-known public packages; private packages need explicit entry in config/bundles.php. Irrelevant for Laravel but a hint about the package's maturity: it assumes you know the wiring.
symfony/framework-bundle as a hard dependency is close to a blocker. Confirm before writing any Laravel code. Pulling http-kernel and framework-bundle into Laravel is possible in isolation but invites version conflicts with Laravel's pinned symfony/console, symfony/process, and symfony/error-handler. Run composer why-not symfony/framework-bundle to see the collision before you start.
Check PHP floor, not just ceiling. Symfony 4.x-era bundles commonly declare php: >=7.2 and then use syntax that fails on 8.3+ deprecations. Run the test suite on your actual runtime.
No license file = no code in your repo. Bitbucket private repos are routinely unlicensed. That's a legal blocker, not a technical one — get written permission from HPro Travel or fork with their sign-off before committing code.
Token cache key must include the service account identity and scopes. Cache keyed only on project will serve dev tokens to prod when a misconfigured .env puts both on the same Redis. Include hash(email + scope_hash).
Clock skew kills homegrown JWT signing silently. If the package signs JWTs itself rather than delegating to google/auth, test against a container with chrony disabled. Symfony-era code frequently gets iat/exp wrong.
Scope misconfiguration surfaces as 403, not a clear error. If a call returns PERMISSION_DENIED, first suspect scopes in the config tree, second suspect IAM on the service account. Dump the JWT claims once in a debug route to see what was actually requested.
Error shapes vary across GCP REST surfaces. Storage returns {"error":{"code","message"}}; Secret Manager sometimes returns {"error":{"status":"NOT_FOUND"}}. The bundle likely surfaces raw responses. Normalize to exceptions in your wrapper — ->throw() on Laravel's HTTP client handles the HTTP-level cases but you should still inspect error.status for actionable messages.
Retry with backoff is not guaranteed. Confirm the bundle implements 429/503 retries with jitter; if not, add them in your wrapper. GCP REST API rate limits are hard to reason about and a naive retry turns a quota blip into a retry storm.
Do not assume pagination. REST list endpoints return nextPageToken. Thin wrappers frequently ignore it, silently truncating results. If you need paginated calls, wrap the client's list method, not the bundle's.
Watch for Symfony DI leakage in the extracted core. If a client constructor takes Symfony\Component\HttpClient\HttpClientInterface, Symfony\Component\Cache\Adapter\AdapterInterface, or Symfony\Component\EventDispatcher\EventDispatcherInterface, swap those for PSR-18/PSR-6/PSR-14 equivalents — Laravel binds PSR-3 and PSR-6 out of the box, and PSR-18 via symfony/http-client which Laravel already ships.
Version-pin and tag it in your own repo. Because you cannot rely on the upstream to keep the commit reachable (Bitbucket access lapses, repo is archived), fork to an internal repo on day one. The bundle becomes yours the moment it lands in composer.json, and pretending otherwise is how you end up with a build failure six months from now.
Extension point to preserve: the token provider. It's the one piece that is both security-sensitive and likely to need modification (per-tenant credentials, workload identity, ADC fallback). Keep it as a contract from the start, even if the bundle doesn't.
If the first extraction day produces more than ~200 lines of glue, stop. At that point the bundle is a reference, not a dependency, and google/auth plus Laravel's HTTP client is faster to own.
How can I help you explore Laravel packages today?