google/shopping-merchant-datasources
PHP client library for the Google Merchant Center Data Sources API. It lets developers create, update, and manage product data sources for Shopping ads and free listings from Laravel or PHP applications using Google's generated API classes.
This is a generated (GAPIC) Google Cloud client, not a Laravel package. There is no service provider, facade, config file, or migration — it is a thin, typed wrapper over the Merchant API DataSources service, and you are responsible for every Laravel-ism (binding, config, queueing, retries, logging).
1. Install and confirm PHP target
composer require google/shopping-merchant-datasources
The library pulls in google/gax, google/protobuf, guzzlehttp/guzzle and optionally ext-grpc. Check the composer.json require block before assuming it works on an older PHP 8.x you might be running elsewhere in the app.
2. Give the app credentials
In Merchant Center, add the service account e-mail as a user on the account (Admin is required to manage data sources). Then point the client at a key file:
// config/services.php
'google_merchant' => [
'credentials' => env('GOOGLE_MERCHANT_CREDENTIALS_PATH'),
'account_id' => env('GOOGLE_MERCHANT_ACCOUNT_ID'), // Merchant Center ID, e.g. 123456789
'transport' => env('GOOGLE_MERCHANT_TRANSPORT', 'rest'),
],
Keep the JSON out of the repo. Either store the path to a file outside the webroot, or (for container/Heroku-style deploys) store a base64 blob in the env and decode it into a temp file at boot.
3. Bind a shared client in a service provider
Building the client is comparatively expensive (auth + transport setup), so make it a singleton rather than new-ing it inside loops:
$this->app->singleton(DataSourcesServiceClient::class, function () {
return new DataSourcesServiceClient([
'credentials' => config('services.google_merchant.credentials'),
'transport' => config('services.google_merchant.transport'),
'timeoutMillis' => 15000,
]);
});
4. First use case: prove connectivity by listing
Listing is the safest first call — read-only, no side effects, and it tells you immediately whether auth, account ID, and API enablement are correct:
use Google\Shopping\Merchant\DataSources\V1\Client\DataSourcesServiceClient;
use Google\Shopping\Merchant\DataSources\V1\ListDataSourcesRequest;
$client = app(DataSourcesServiceClient::class);
$parent = 'accounts/'.config('services.google_merchant.account_id');
$request = ListDataSourcesRequest::build($parent);
foreach ($client->listDataSources($request)->iteratePages() as $page) {
foreach ($page as $dataSource) {
// $dataSource->getName() => accounts/123456789/dataSources/987654
// $dataSource->getDisplayName()
}
}
Where to look first in the repo: src/V1/Client/DataSourcesServiceClient.php (every method you can call), src/V1/DataSource.php (the Type oneof — primary product, supplemental product, promotion, local/regional inventory, product review, merchant review, return policy), and src/V1/resources/ if you need the raw REST description. That oneof is the true shape of the API; everything else is plumbing.
Wrap the generated client behind your own service/interface. The generated classes are final-ish, mock-hostile, and named after protobuf rather than your domain. One thin adapter buys you testability and a place to put retries:
interface MerchantDataSourceGateway
{
public function list(string $accountId): Collection; // of DataSource DTOs
public function create(string $accountId, DataSourceDraft $draft): string;
public function updateDisplayName(string $name, string $displayName): void;
public function delete(string $name): void;
}
Bind the real implementation to the interface in your provider; bind a fake in tests/. This is the single highest-leverage decision when using a GAPIC client in Laravel, because Http::fake() does not intercept it.
Provisioning on tenant creation (event → job). The most common day-to-day use: when a merchant connects their store, create the primary product data source that your feed pipeline will write into.
public function handle(DataSourcesServiceClient $client, string $accountId): void
{
$dataSource = new DataSource([
'display_name' => 'Store sync feed',
'primary_product_data_source' => new PrimaryProductDataSource([
'content_language' => 'en',
'feed_label' => 'US',
'countries' => ['US'],
]),
]);
$created = $client->createDataSource(
CreateDataSourceRequest::build("accounts/{$accountId}", $dataSource)
);
// Persist the numeric ID from the resource name, not the whole string,
// and keep the full name for convenience.
$this->account->dataSources()->create([
'resource_name' => $created->getName(),
'google_id' => basename($created->getName()),
'display_name' => $created->getDisplayName(),
]);
}
Dispatch this after the DB transaction commits (DB::afterCommit / ShouldDispatchAfterCommit), otherwise a rolled-back tenant can leave an orphaned data source in Google's system that you'll never find again.
Nightly reconciliation job. Treat Google as the source of truth for existence and your DB as a cache. A scheduled list + upsert gives you drift detection for free (feeds deleted in the Merchant Center UI, renamed, etc.):
foreach ($client->listDataSources(ListDataSourcesRequest::build($parent))->iteratePages() as $page) {
foreach ($page as $ds) {
DataSourceModel::updateOrCreate(
['google_id' => basename($ds->getName())],
['display_name' => $ds->getDisplayName(), 'synced_at' => now()]
);
}
}
Updates always need a field mask. There is no "partial save" magic; without a mask the API will either reject the request or overwrite fields you didn't intend to touch:
$patch = new DataSource(['name' => $resourceName, 'display_name' => $newName]);
$client->updateDataSource(new UpdateDataSourceRequest([
'data_source' => $patch,
'update_mask' => new FieldMask(['paths' => ['display_name']]),
]));
Queue every write. All calls are synchronous Guzzle/gRPC round-trips. A loop that creates ten data sources inside a web request is ten sequential network calls with default timeouts — a page that times out on a slow Google day. Jobs also give you free retries with $tries/backoff().
Handle errors as a taxonomy, not a try/catch blob. Google\ApiCore\ApiException carries a canonical status string:
catch (ApiException $e) {
match ($e->getStatus()) {
'ALREADY_EXISTS' => $this->reconcileExisting($e), // safe to look up and continue
'NOT_FOUND' => $this->forgetDataSource($e),
'PERMISSION_DENIED'=> throw new MerchantAuthException(previous: $e),
'INVALID_ARGUMENT' => throw new MerchantValidationException($e->getMessage(), previous: $e),
'RESOURCE_EXHAUSTED', 'UNAVAILABLE' => $this->release(60), // let the queue back off
default => throw $e,
};
}
Multi-account / multi-tenant. One client instance handles many accounts — only the parent string changes. Only reach for per-tenant credentials if you're acting on behalf of merchants via OAuth rather than a single agency service account.
Batch reporting. If you're building an admin screen, cache the list result (Cache::remember(..., 300)) instead of hitting Google on every page view — the data changes on the order of hours, not seconds.
env() in a cached config is null. Always read credentials through config('services.google_merchant.*'), never env() inside a provider or job. This is the #1 cause of "works locally, fails in production."GOOGLE_APPLICATION_CREDENTIALS. If you rely on ADC rather than the credentials option, the env var must be set before the client is constructed. In a queued worker that's easy to get wrong; passing credentials explicitly to the constructor avoids the whole class of problem.ext-grpc isn't installed the client silently falls back to REST, which is fine but slower per call. Setting 'transport' => 'rest' explicitly in config makes behaviour identical across dev/CI/prod instead of varying by whoever's laptop has the extension.Http::fake() does nothing here. Tests must either mock the MerchantDataSourceGateway interface (recommended) or inject a Guzzle MockHandler via the httpHandler client option plus a stub credential fetcher. Don't discover this halfway through a test suite.['display_name' => ...], ['primary_product_data_source' => ...]. The generated getters are camelCase (getDisplayName()). Mixing the two in one file is the most common source of confusing InvalidArgument/TypeError output.DataSource type is a oneof. Setting both primary_product_data_source and promotion_data_source on one object throws. One data source = one feed type; model that in your DB schema (a type column) rather than trying to cram everything into one row.accounts/{account}/dataSources/{id} and are what update/delete require. Store the numeric ID for your own URLs but always rebuild or store the full name for API calls; hardcoding accounts/X/dataSources/Y from a different environment's account ID is a classic staging/prod leak.iterateAllElements() is convenient but materialises the entire listing in memory; use iteratePages() and chunk your upserts for accounts with many data sources.create is not retried — if you need at-least-once semantics, make your job idempotent by looking up by display name / feed label before creating, or catch ALREADY_EXISTS.timeoutMillis unset means a queued job can sit for tens of seconds on a hung connection. Set it explicitly and give the job a retryUntil.FAILED_PRECONDITION; treat deletion as an explicit, admin-confirmed action, not a cascade from deleting your local tenant.google/shopping-merchant-products, ...-inventories, ...-reports, ...-accounts). Scope your feature expectations accordingly — this is the "which feeds exist and how are they configured" package, not the "push a catalog" package.google/gax + protobuf constraints can conflict with other Google packages already in the app (analytics, firebase, cloud storage). When composer update explodes, resolve it at the google/gax / google/protobuf level rather than downgrading this package.parent, method, and ApiException::getStatus(). When a merchant says "my feed disappeared," that log is the only forensic trail you'll have.options, not by editing vendor. retrySettings, timeoutMillis, transport, and universeDomain are all accepted by the client constructor; that's the sanctioned extension point. Never patch files under vendor/google/.How can I help you explore Laravel packages today?