google/cloud-pubsub
Idiomatic PHP client for Google Cloud Pub/Sub, a fully-managed real-time messaging service for sending and receiving messages between independent applications. Install via Composer; supports REST and gRPC with built-in authentication helpers.
1. Install with gRPC, not just the package. The package works over REST, but streaming pull and lower latency need the extension:
composer require google/cloud-pubsub
pecl install grpc protobuf # then enable in php.ini
Verify with php -m | grep -E 'grpc|protobuf'. Without gRPC you fall back to HTTP/1.1, which is fine for publishing from a web request but a poor fit for consumers.
2. Know that there are two clients in this package. The README sample shows the generated GAPIC client (Google\Cloud\PubSub\V1\Client\PublisherClient, SubscriberClient). Day-to-day you will almost always want the ergonomic wrapper:
use Google\Cloud\PubSub\PubSubClient;
$pubsub = new PubSubClient([
'projectId' => config('services.pubsub.project'), // or auto-detected from ADC
]);
Start reading at src/PubSubClient.php, then src/Topic.php, src/Subscription.php, src/Message.php. The GAPIC classes live under src/V1/ and are worth opening only when the wrapper is missing a field.
3. Authenticate with Application Default Credentials. In production use a service account attached to the workload (GKE/Cloud Run/App Engine) or GOOGLE_APPLICATION_CREDENTIALS pointing at a mounted key file. Never pass credentials inline — keyFile/keyFilePath/credentials client options are deprecated as of v2.15.0.
4. First use case: publish a domain event from an existing app.
$topic = $pubsub->topic('orders');
$topic->create(); // idempotent-ish; guard with exists() in real code
$topic->publish([
'data' => json_encode(['order_id' => $order->id, 'total' => $order->total]),
'attributes' => ['type' => 'order.created', 'tenant' => (string) $order->tenant_id],
]);
Then a consumer:
$subscription = $pubsub->subscription('orders-worker', 'orders'); // topic matters on create
foreach ($subscription->pull(['maxMessages' => 50]) as $message) {
process(json_decode($message->data(), true));
$subscription->acknowledge([$message]);
}
That is the whole loop. Everything below is about making it survive production.
Bind one client as a singleton. gRPC channel setup is not free; constructing PubSubClient per request/command is a measurable cost and can leak file descriptors.
// AppServiceProvider::register()
$this->app->singleton(PubSubClient::class, fn () => new PubSubClient([
'projectId' => config('services.pubsub.project'),
'logger' => app(\Psr\Log\LoggerInterface::class), // supported client option
]));
Wrap it behind your own interface. The SDK has no fake, and mocking Topic/Subscription is painful. A thin MessageBus contract (publish(string $type, array $payload, array $attributes)) with a Pub/Sub implementation plus an in-memory fake pays for itself in the first week of tests.
Envelope your messages. Attributes are filterable and cost nothing extra; the payload is not. Put routing metadata (type, tenant, correlation_id, schema_version) in attributes and keep the body as raw JSON. Remember data must be a string — the client base64-encodes for you, so json_encode() it and never double-encode.
Subscriptions are configuration, not code. Create them once (deploy script, Terraform, or a guarded exists()/create() block) with the properties that matter:
$pubsub->subscription('orders-worker', 'orders')->create([
'ackDeadlineSeconds' => 60, // 10–600
'messageRetentionDuration' => '604800s', // default 7d, max 31d
'retryPolicy' => ['minimumBackoff' => '10s', 'maximumBackoff' => '600s'],
'deadLetterPolicy' => [
'deadLetterTopic' => $pubsub->topic('orders-dlq')->name(),
'maxDeliveryAttempts' => 5,
],
'expirationPolicy' => ['ttl' => '31d'], // else idle subs vanish
]);
Also common: ['pushConfig' => ['pushEndpoint' => 'https://app.example.com/pubsub/orders']] for HTTP delivery, enableExactlyOnceDelivery => true, enableMessageOrdering => true, filter for attribute-based fan-out, and cloudStorageConfig/bigQueryConfig when you want zero-code sinks.
Consumers should be Artisan commands under Supervisor (or Horizon-style workers). One command, one subscription, signal-aware shutdown:
protected $signature = 'pubsub:consume {subscription} {--max=100}';
public function handle(PubSubClient $pubsub): int
{
$subscription = $pubsub->subscription($this->argument('subscription'));
while ($this->running) {
$messages = $subscription->pull([
'maxMessages' => (int) $this->option('max'),
'returnImmediately' => true, // keeps the loop responsive to SIGTERM
]);
if (! $messages) { usleep(250_000); continue; }
foreach ($messages as $message) {
try {
$this->dispatchNow(new HandleEvent($message));
$subscription->acknowledge([$message]);
} catch (\Throwable $e) {
report($e); // no ack → redelivery after ack deadline
}
}
gc_collect_cycles();
}
return self::SUCCESS;
}
Register pcntl_signal(SIGTERM, ...) to flip $this->running. On deploy, let the worker finish its batch and exit rather than kill -9.
For long jobs, decouple ack from work. Pulling and then handing each message to a queued Laravel job means the ack deadline expires while the job waits. Either (a) do the work inside the pull loop and modifyAckDeadline($messages, 600) as a heartbeat for slow handlers, or (b) copy the payload into your own queue and ack immediately, accepting at-most-once for that hop.
Push subscriptions map cleanly onto a Laravel route. Register a controller, disable CSRF for it, verify the Google-signed OIDC JWT in the Authorization: Bearer header (issuer https://accounts.google.com, audience = your endpoint, email = your push service account), then return 2xx to ack, 5xx to retry. Use the token's email claim as the gate — an unauthenticated push endpoint is an open door.
Prefer filtering over fan-out duplication. One topic with several subscriptions, each with an attribute filter, is cheaper and simpler than many topics. Use separate topics when retention, IAM, or schema differ.
Testing strategy: run the official emulator in CI (gcloud beta emulators pubsub start), export PUBSUB_EMULATOR_HOST=localhost:8085 and create the client with an insecure/emulator-friendly config. Treat the emulator as a smoke test — it does not enforce real quotas, retry policies, dead lettering, or exactly-once semantics, so also cover ack/retry logic with your MessageBus fake.
PublisherClient sample is a generated-code formality; the wrapper (PubSubClient) is what has topic(), subscription(), batching, IAM helpers, and snapshots. Don't conclude the library is verbose based on the README alone.subscription($name) without a topic cannot be created. create() needs the topic to build projects/*/topics/*; pass it as the second argument ($pubsub->subscription('name', 'topic')) or supply the fully-qualified resource name.order_id + event id) or opt into enableExactlyOnceDelivery — which is per-subscription, not free (throughput cost), and surfaces ack failures you must handle instead of swallowing.ackDeadlineSeconds (max 600) and extend mid-flight for long handlers. pull() blocks; returnImmediately => true is what makes signals and shutdown work.acknowledge() returns void. Without exactly-once, a failed ack is silent — the message comes back later and you get to debug it in production. Log ack outcomes and watch num_undelivered_messages / oldest-unacked-age in Cloud Monitoring.dispatchAfterResponse() or a queued publish job keeps p99 latency down. When you do batch, pass an array of messages to a single publish() (one API request, subject to the 1000-message / 10 MB request cap) — there is no background flusher like the Node client, so your loop's shape is your throughput.data is a string. Passing an array throws. Base64-encode only if your payload is binary — for JSON, don't.enableMessageOrdering, one failed publish stalls subsequent messages for that key; you must resume with the same orderingKey and know that redelivery can produce out-of-order across keys. Only use it when you actually need per-entity ordering.ServiceException on publish does not mean the message wasn't accepted. Carry your own event UUID in attributes and dedupe downstream, or accept the duplication.keyFile / keyFilePath / credentials are deprecated (v2.15.0, and the v2.14.0 credentials-client-option deprecation). Move to ADC now; it's a one-line change while you remember, and a nasty surprise during a Google Cloud migration later.messageTransforms (v2.10.0, with enabled deprecated for disabled in v2.11.0), AIInference transforms, Bigtable ingestion, compiled_proto_schema. None of these break you, but they're invisible unless you read releases — and the underlying API silently gains features your code should be using.logger in client options to see RPC-level logs; wrap calls and inspect Google\ApiCore\ApiException (GAPIC) or Google\Cloud\Core\Exception\ServiceException subclasses (wrapper) including getStatus()/getDetails(). Log PubSubClient::SERVICE_NAME and the package version at boot — added in v2.9.1 and worth having in incident tickets.app()->forgetInstance(...)), avoid accumulating messages in arrays, and call gc_collect_cycles() between batches. Also cap batch size — maxMessages => 1000 with fat payloads is a fine way to OOM.PUBSUB_EMULATOR_HOST exists in production, auth is bypassed and traffic goes to localhost. Add it to your config-exposure checks and never default it in config/services.php.How can I help you explore Laravel packages today?