zendframework/zendservice-google-gcm
A Zend Framework 2 service for sending push notifications via Google Cloud Messaging (GCM). It wraps the GCM HTTP endpoint, letting PHP apps deliver messages to Android devices through a simple object-oriented API. Note: GCM was retired in 2019 in favor of Firebase Cloud Messaging (FCM).
zendframework/zendservice-google-gcm is a small ZF2 module that wraps the (now defunct) Google Cloud Messaging HTTP endpoint. You don't need ZF2 or a module manager to use it from Laravel — Composer autoloads the classes and you instantiate them directly.
composer require zendframework/zendservice-google-gcm
Composer will print Package zendframework/zendservice-google-gcm is abandoned and likely pull in zendframework/zend-http and zendframework/zend-json, which are also abandoned. On modern PHP this is the first thing to verify before writing any code:
php -r "require 'vendor/autoload.php'; new ZendService\Google\Gcm\Client(); echo 'ok';"
Where to look first
src/ZendService/Google/Gcm/Client.php — ~150 lines; the whole package is basically setApiKey() plus send(Message $message).src/ZendService/Google/Gcm/Message.php — the request DTO (setRegistrationIds, addData, setCollapseKey, setTimeToLive, setDryRun, …).src/ZendService/Google/Gcm/Response.php — parses the JSON reply into Result objects.Module.php — ignore it entirely. It exists only so ZF2's ModuleManager could register config; Laravel's Composer autoloader is all you need.First use case
The canonical flow: a queued job takes a batch of Android device tokens, builds a Message, sends it, and interprets per-token results.
use ZendService\Google\Gcm\Client;
use ZendService\Google\Gcm\Message;
$client = new Client();
$client->setApiKey(config('services.gcm.api_key'));
$message = new Message();
$message->setRegistrationIds($tokens);
$message->setData(['title' => 'Hi', 'body' => 'Hello']);
$message->setTimeToLive(3600);
$response = $client->send($message);
Before building anything on this, confirm the endpoint is still reachable — see Gotchas.
Bind it once in a service provider. The client is stateless apart from the API key, so a singleton is fine.
// app/Providers/AppServiceProvider.php
$this->app->singleton(Client::class, function () {
$client = new Client();
$client->setApiKey(config('services.gcm.api_key'));
return $client;
});
// config/services.php
'gcm' => [
'api_key' => env('GCM_API_KEY'),
],
Wrap it, don't sprinkle it. The package has no Laravel idioms (no facade, no config publishing, no queue integration). A thin application service gives you a seam for testing and for swapping to FCM later.
final class GcmPusher
{
public function __construct(private Client $client) {}
/** @param string[] $tokens */
public function send(array $tokens, array $data): array
{
$results = [];
foreach (array_chunk($tokens, 1000) as $chunk) {
$message = (new Message())
->setRegistrationIds($chunk)
->setData($data)
->setTimeToLive(3600);
$response = $this->client->send($message);
foreach ($response->getResults() as $i => $result) {
$results[$chunk[$i] ?? $i] = $result;
}
// back-pressure between chunks
usleep(100_000);
}
return $results;
}
}
Always chunk at 1000. A single GCM request accepts at most 1000 registration IDs. Response::getResults() is positional — $results[$i] corresponds to $registrationIds[$i] in the message you sent, so keep the chunk array around to map errors back to tokens.
Handle canonical IDs and dead tokens in the same loop. This is the part people skip, and it's why old apps accumulate thousands of stale tokens.
foreach ($response->getResults() as $i => $result) {
$token = $chunk[$i];
if ($error = $result->getError()) {
match ($error) {
'NotRegistered', 'InvalidRegistration' => DeviceToken::where('token', $token)->delete(),
'Unavailable', 'InternalServerError',
'DeviceMessageRateExceeded' => $retry[] = $token,
default => report(new PushFailure($token, $error)),
};
continue;
}
if ($canonical = $result->getRegistrationId()) {
DeviceToken::where('token', $token)->update(['token' => $canonical]);
}
}
Push from a queued job, one job per 1000-token batch. The HTTP call is synchronous and can take seconds; you don't want it inside a request cycle.
final class SendPush implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public array $backoff = [10, 60, 300];
public function __construct(public string $token, public array $payload) {}
public function handle(GcmPusher $pusher): void
{
$pusher->send([$this->token], $this->payload);
}
}
With $backoff, the queue handles Unavailable retries for you — just throw on retryable errors and record non-retryable ones.
Payload rules to encode in your DTO. GCM data values must be strings; nested arrays are not allowed.
$message->setData(array_map(
fn ($v) => is_scalar($v) ? (string) $v : json_encode($v),
$payload
));
Dry run for staging. setDryRun(true) validates the message without delivering, but it still makes a network request — it is not a local mock.
Testing. Http::fake() and Http::assertSent() do nothing here; the package uses Zend\Http\Client directly, not the Laravel HTTP client or Guzzle. Test by binding a fake into the container:
$this->mock(Client::class, function ($mock) use ($response) {
$mock->shouldReceive('send')->once()->andReturn($response);
});
or, cleaner, mock your own GcmPusher and keep the package out of your test suite entirely.
The service is dead. GCM was deprecated in April 2018 and shut down in April 2019; android.googleapis.com/gcm/send no longer accepts traffic. Every request from this package will fail against production. The library is archived, has 28 stars and zero dependents, and the last release (2019-02-07) predates the shutdown. Treat any use of it as a historical exercise or a stopgap while you migrate.
The API key doesn't exist anymore. The Authorization: key=<server key> model was replaced by FCM HTTP v1, which uses OAuth2 bearer tokens minted from a service account. There is no way to obtain a legacy GCM server key. If config('services.gcm.api_key') is null or a placeholder, Client::send() will still POST and you'll get a 401 — with no hint that the credential type itself is retired.
Migration path. The direct replacement is FCM HTTP v1 (https://fcm.googleapis.com/v1/projects/{project}/messages:send), one token per request, OAuth2 auth. In Laravel: kreait/laravel-firebase or laravel-notification-channels/fcm. Your GcmPusher interface should be designed so the body can be swapped without touching call sites — which is the main argument for wrapping it rather than injecting Client into controllers.
PHP 8 / dependency rot. The package is PSR-0-autoloaded and depends on zendframework/zend-http and zendframework/zend-json, both archived and superseded by Laminas. On PHP 8.1+ you'll hit deprecation notices from the Zend HTTP layer (implicit nullable params, dynamic properties). If Composer resolution blocks you, zendframework/zend-http can often be substituted with laminas/laminas-http via a replace block, but the package's use Zend\Http\... statements won't be rewritten — you'd need a fork.
No Laravel HTTP integration. There's no way to inject Zend\Http\Client through the constructor; Client creates its own. That means no Http::fake(), no global middleware, no Http::retry(), and no per-request timeout config without subclassing Client and overriding send(). Timeouts default to Zend's own (10s connect / no explicit total), which is a common source of stuck queue workers.
Response parsing is thin. Response::getResults() returns Result objects; getError() returns null on success and getRegistrationId() returns an empty string (not null) when there is no canonical ID — guard with if ($canonical !== '') rather than a truthiness check on a method chain.
Error codes worth a lookup table:
| Error | Meaning | Action |
|---|---|---|
NotRegistered |
Token no longer valid | Delete token |
InvalidRegistration |
Malformed token | Delete token |
MismatchSenderId |
Token belongs to another project | Delete token |
Unavailable |
Server-side hiccup | Retry with backoff |
InternalServerError |
Transient | Retry with backoff |
DeviceMessageRateExceeded |
Per-device flood | Backoff |
MessageTooBig |
> 4 KB total payload | Trim / send ID only |
InvalidTtl |
TTL out of range | Fix config |
Rate limits are yours to manage. The package has no throttling and no sendMulti. Fan-out to 100k devices means 100 serial HTTP requests; concurrency and rate limiting live entirely in your job batching.
Extension points are limited to two. Subclass Client to override send() (custom timeout, logging, metrics) or to inject a configured HTTP client; subclass Message if you need a typed payload builder. There is no event system, no middleware pipeline, and no response hook.
Uninstall before you forget. If you're only reading this to evaluate the package, skip the install — composer require will drag three abandoned Zend packages into your composer.lock and trigger composer audit warnings forever.
How can I help you explore Laravel packages today?