PHPackages                             laith343/laravel-fcm-blast - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Mail &amp; Notifications](/categories/mail)
4. /
5. laith343/laravel-fcm-blast

ActiveLibrary[Mail &amp; Notifications](/categories/mail)

laith343/laravel-fcm-blast
==========================

High-throughput Firebase Cloud Messaging blaster for Laravel. Pushes 10k+ req/s via a reused curl\_multi handle pool, OAuth handled by kreait/firebase-php.

v0.7.2(yesterday)01↑2900%MITPHPPHP ^8.3CI passing

Since Jun 5Pushed yesterdayCompare

[ Source](https://github.com/LAITH343/laravel-fcm-blast)[ Packagist](https://packagist.org/packages/laith343/laravel-fcm-blast)[ Docs](https://github.com/LAITH343/laravel-fcm-blast)[ RSS](/packages/laith343-laravel-fcm-blast/feed)WikiDiscussions master Synced yesterday

READMEChangelogDependencies (8)Versions (10)Used By (0)

laravel-fcm-blast
=================

[](#laravel-fcm-blast)

`laith343/laravel-fcm-blast`

[![Packagist Version](https://camo.githubusercontent.com/57509874d8e7668db540cae090268ab7f290fcb23b4b6893567bda7e553f28bb/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f6c616974683334332f6c61726176656c2d66636d2d626c617374)](https://packagist.org/packages/laith343/laravel-fcm-blast)[![PHP Version](https://camo.githubusercontent.com/3ccfa5e17a19b9245ee83903b2e4ead58f1bf72b869657faa0b857ae339161e6/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f7068702d762f6c616974683334332f6c61726176656c2d66636d2d626c617374)](https://packagist.org/packages/laith343/laravel-fcm-blast)[![License](https://camo.githubusercontent.com/54b0daef9dc851ce2ad58d0fa1b099ff5ca64f4a818b416428be7368496712cb/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f6c2f6c616974683334332f6c61726176656c2d66636d2d626c617374)](LICENSE)

High-throughput Firebase Cloud Messaging sender for Laravel. Pushes **10k+ req/s** from PHP by driving a reused `curl_multi` handle pool with persistent TCP — OAuth handled by `kreait/firebase-php`, delivery handled by the engine.

This package is **backend only**. It exposes a programmatic API and a status DTO; build whatever UI/monitoring you want on top of `status()`.

Contents
--------

[](#contents)

- [Requirements](#requirements)
- [Supported versions](#supported-versions)
- [Install](#install)
- [Configure](#configure)
- [Environment variables](#environment-variables)
- [Sizing for your target RPS](#sizing-for-your-target-rps)
- [Integrate](#integrate)
- [Per-request debug logging](#per-request-debug-logging)
- [Run a blast](#run-a-blast)
- [Run locally](#run-locally)
- [Run in production](#run-in-production)
- [Monitor](#monitor)
- [How it hits 10k RPS](#how-it-hits-10k-rps)

Requirements
------------

[](#requirements)

RequirementVersionWhyPHP`^8.3`Engine + typed contracts.Laravel (`illuminate/*`)`^12.0` | `^13.0`Queue, Eloquent, container, facades.`ext-curl`\*`curl_multi` is the delivery engine.`ext-json`\*Payload encoding.`kreait/firebase-php``^7.0`Service-account OAuth token minting.Redis—Queue connection; required for the stale-job purge on start.DatabasePostgres / MySQLHolds the per-run counters. The engine flushes **atomic** `col = col + delta` updates concurrently from every worker.> **SQLite caveat:** fine for the test suite and single-worker dev runs, but its single-writer lock serializes the concurrent counter flushes — do not use it for multi-worker production blasts. Use Postgres or MySQL there.

Supported versions
------------------

[](#supported-versions)

PackagePHPLaravel`0.x`8.3+12.x, 13.xInstall
-------

[](#install)

```
composer require laith343/laravel-fcm-blast
php artisan vendor:publish --tag=fcm-blast-config
php artisan migrate
```

Configure
---------

[](#configure)

`config/fcm-blast.php`:

```
'credentials' => env('FIREBASE_CREDENTIALS'),   // service-account JSON path or string (null = fake-token test mode)
'project_id'  => env('FIREBASE_PROJECT_ID'),
'rate_cap_per_sec' => 10000,                    // global cap, split across workers
'token_source'    => App\Fcm\MyTokenSource::class,
'message_builder' => App\Fcm\MyMessageBuilder::class,
'invalid_token_handler' => App\Fcm\PruneToken::class, // optional
'http_version'         => '2tls',               // HTTP/2 for real FCM, HTTP/1.1 for cleartext mocks
'max_host_connections' => 16,                   // small for HTTP/2 (real FCM); null = rateCap*0.3 (HTTP/1.1 mock)
```

> **Real FCM needs HTTP/2.** Over HTTP/1.1 every in-flight request needs its own TLS connection, so high concurrency to `fcm.googleapis.com` exhausts sockets (timeouts, broken pipes). `http_version => '2tls'` (the default) multiplexes thousands of requests over a handful of connections — set `max_host_connections` to something small (8-32). `2tls` automatically uses HTTP/1.1 for plain-`http` endpoints, so a local mock is unaffected and can keep its many-connections setup (`max_host_connections => null`).

Environment variables
---------------------

[](#environment-variables)

Every config key has an env override:

Env varDefaultWhat it does`FCM_BLAST_CREDENTIALS`—Service-account JSON path or string. Empty = fake-token mode (mock testing).`FCM_BLAST_PROJECT_ID`—Firebase project id; builds the FCM v1 endpoint.`FCM_BLAST_ENDPOINT`—Override endpoint. Empty = real FCM. Set to a mock URL for load testing.`FCM_BLAST_RATE_CAP``10000`**Global** sends/sec cap, split across workers (`10000` = 600k/min).`FCM_BLAST_RATE_BURST`5% of rateMax instantaneous burst per worker (paced limiter). Lower = smoother; doesn't change sustained rate.`FCM_BLAST_MAX_HOST_CONNECTIONS``16`TCP connections per **worker**. Null = `rateCap*0.3` (HTTP/1.1 mock).`FCM_BLAST_MAX_CONCURRENT_STREAMS``100`Max concurrent HTTP/2 streams per connection (FCM drops &gt;~100).`FCM_BLAST_HTTP_VERSION``2tls``2tls` (HTTP/2 over TLS, 1.1 over cleartext), `2`, or `1.1`.`FCM_BLAST_MAX_RETRIES``5`Attempts per token for 429/503 + transient transport errors.`FCM_BLAST_QUEUE``fcm-blast`Queue name the jobs run on.`FCM_BLAST_CONNECTION``redis`Queue connection (must be `redis` for stale-job purging).> `max_host_connections` is **per worker**. Total connections to FCM = `workers × max_host_connections`. In-flight capacity per worker = `max_host_connections × max_concurrent_streams` (the engine sizes its concurrency to exactly this).

Sizing for your target RPS
--------------------------

[](#sizing-for-your-target-rps)

Throughput is governed by **Little's Law**:

```
in-flight needed = target_RPS × avg_latency_seconds
capacity         = workers × max_host_connections × max_concurrent_streams

```

To hit a target RPS, make `capacity ≥ in-flight needed`, and keep `FCM_BLAST_RATE_CAP` at the rate you want (it caps emission so you never exceed it).

**Worked example — 10,000 RPS (600k/min):**

avg latencyin-flight neededexample config (capacity)300 ms3,0004 workers × 8 × 100 = 3,200600 ms6,0004 workers × 16 × 100 = 6,4001,000 ms10,0004 workers × 25 × 100 = 10,000```
FCM_BLAST_RATE_CAP=10000
FCM_BLAST_MAX_HOST_CONNECTIONS=16   # per worker
FCM_BLAST_MAX_CONCURRENT_STREAMS=100
```

with **4 workers** → 6,400 in-flight, enough for 10k RPS up to ~640ms latency; `rate_cap` then holds the ceiling at 10k.

**Three real limits, in order of what you'll hit first:**

1. **Network egress** — a single host (especially residential/cellular) tolerates only so many simultaneous connections. Failures show as transport errors (timeouts/resets), now auto-retried. Keep `max_host_connections` to what your link handles; scale out across hosts for more.
2. **Latency** — higher latency needs more in-flight for the same RPS. Measure `avg_latency_ms` from a run and plug it into the formula.
3. **FCM project quota** — above it, FCM returns `429`/`503` (the `throttled` counter). Check/raise it in Google Cloud Console → IAM &amp; Admin → Quotas → "Firebase Cloud Messaging API".

> Don't over-provision connections. Use the **smallest** `connections × streams` that covers your latency — extra connections only increase the handshake burst (more transport failures) without raising throughput, since `rate_cap` is the real ceiling.

Integrate
---------

[](#integrate)

Implement two contracts. The package never touches your database directly.

```
use Laith343\FcmBlast\Contracts\TokenSource;

class MyTokenSource implements TokenSource
{
    public function count(): int { /* total active tokens */ }
    public function stream(int $offset, int $limit): \Generator { /* yield device tokens */ }
}
```

```
use Laith343\FcmBlast\Contracts\MessageBuilder;

class MyMessageBuilder implements MessageBuilder
{
    // Return the FCM v1 "message" body; the engine injects `token` and the envelope.
    // $context is null unless your TokenSource attached one (see below).
    public function build(string $token, mixed $context = null): array
    {
        return ['notification' => ['title' => 'Hi', 'body' => 'There']];
    }
}
```

### Per-token context (localization, custom logic)

[](#per-token-context-localization-custom-logic)

To tailor the message per user, have your `TokenSource` yield an `OutboundToken`(token + arbitrary context) instead of a plain string. The context reaches `build()` unchanged. It's fully optional — yielding a string keeps `$context` null, so existing sources/builders are unaffected.

```
use Laith343\FcmBlast\Support\OutboundToken;

// In TokenSource::stream()
yield new OutboundToken($user->fcm_token, ['locale' => $user->locale, 'name' => $user->name]);
// or, no context:
yield $user->fcm_token;
```

```
// In MessageBuilder::build()
public function build(string $token, mixed $context = null): array
{
    $locale = $context['locale'] ?? 'en';
    return ['notification' => [
        'title' => __('push.title', [], $locale),
        'body'  => __('push.body', ['name' => $context['name'] ?? ''], $locale),
    ]];
}
```

`context` can be any value (array, DTO, model) — it's passed in-process, not serialized.

### Per-run context (which campaign am I serving?)

[](#per-run-context-which-campaign-am-i-serving)

Per-token context covers the message; per-run context tells the resolved `TokenSource` / `MessageBuilder` / `InvalidTokenHandler` instances *which run*they're serving. Workers resolve those classes from the container with no arguments, so without this they can't know the campaign. Implement `ContextAware` and pass the payload via `withContext()`:

```
use Laith343\FcmBlast\Contracts\ContextAware;
use Laith343\FcmBlast\Contracts\TokenSource;

class CampaignTokenSource implements TokenSource, ContextAware
{
    private int $campaignId;

    public function withRunContext(mixed $context): static
    {
        $this->campaignId = $context['campaign_id'];
        return $this;
    }

    public function count(): int { /* tokens for $this->campaignId */ }
    public function stream(int $offset, int $limit): Generator { /* ... */ }
}
```

```
FcmBlast::withContext(['campaign_id' => 42])->start($total, $workers);
```

The context travels through the queued job, so it must be **serializable**(arrays/scalars, or SerializesModels-friendly values) — unlike per-token context, which stays in-process. Opt-in: classes that don't implement `ContextAware` are untouched.

Optional — prune permanently invalid tokens (FCM 404/400):

```
use Laith343\FcmBlast\Contracts\InvalidTokenHandler;

class PruneToken implements InvalidTokenHandler
{
    public function __invoke(string $token): void { /* deactivate $token */ }
}
```

Per-request debug logging
-------------------------

[](#per-request-debug-logging)

Enable to capture every request's full body, context, outcome, HTTP code and latency for later debugging:

```
FCM_BLAST_LOG_REQUESTS=true
FCM_BLAST_LOG_RETENTION_DAYS=7        # files older than this are pruned at run start
FCM_BLAST_LOG_PATH=                   # defaults to storage/logs/fcm-blast
```

Output is one NDJSON record per request, in a daily-rotated file (`fcm-blast-requests-YYYY-MM-DD.log`):

```
{"ts":"2026-06-07T12:00:00+00:00","run_id":42,"token":"...","context":{"locale":"en"},"attempt":1,"http_code":200,"curl_errno":0,"outcome":"Ok","latency_ms":108,"body":{"message":{"notification":{"title":"Hi"},"token":"..."}}}
```

**Performance:** records are buffered in memory and appended in bulk on the engine's existing 0.5s flush tick (with a hard buffer cap), so the send loop does one file append per half-second per worker — not one write per request. This is far cheaper than a per-request log queue (which would dispatch more jobs than the sends themselves), so logging stays in the worker. Keep it off in production unless actively debugging.

Run a blast
-----------

[](#run-a-blast)

```
use Laith343\FcmBlast\Facades\FcmBlast;

$runId = FcmBlast::tokensFrom(MyTokenSource::class)   // optional if set in config
    ->buildMessage(MyMessageBuilder::class)
    ->onInvalidToken(PruneToken::class)               // optional
    ->validateOnly(true)                              // optional dry run: validate, no delivery
    ->start(total: 1_000_000, workers: 4);
```

`start()` returns the run id. Spin up **one queue worker process per worker** — the process count **must** match the `workers` arg, since each job streams its own non-overlapping slice of the tokens.

Run locally
-----------

[](#run-locally)

For development you don't need real Firebase credentials. Two modes:

**1. Fake-token mode** — leave `credentials` empty. The token provider returns a dummy bearer instead of minting a real OAuth token, so you can exercise the full dispatch/queue/counter pipeline without Firebase:

```
FCM_BLAST_CREDENTIALS=
FCM_BLAST_PROJECT_ID=demo
```

**2. Mock endpoint** — point the sender at a local HTTP server to load-test the engine without hitting FCM. Over plain `http` the `2tls` setting auto-falls back to HTTP/1.1, so let the pool grow:

```
FCM_BLAST_CREDENTIALS=
FCM_BLAST_ENDPOINT=http://127.0.0.1:8080/send
FCM_BLAST_MAX_HOST_CONNECTIONS=          # null -> rateCap*0.3 (HTTP/1.1 mock)
```

Start a worker and kick off a blast (one worker shown):

```
php artisan queue:work redis --queue=fcm-blast --tries=1 --timeout=1800 &
php artisan tinker
>>> Laith343\FcmBlast\Facades\FcmBlast::start(total: 1000, workers: 1);
```

For multiple local workers, start that many `queue:work` processes:

**Linux / macOS (bash/zsh)**

```
for i in $(seq 1 4); do
  php artisan queue:work redis --queue=fcm-blast --tries=1 --timeout=1800 &
done
```

**Windows (PowerShell)**

```
1..4 | ForEach-Object {
  Start-Process php -ArgumentList "artisan","queue:work","redis","--queue=fcm-blast","--tries=1","--timeout=1800"
}
```

**Run the test suite:**

```
composer test
```

Run in production
-----------------

[](#run-in-production)

Run the workers under a process supervisor instead of by hand, and make the process count match the `workers` you pass to `start()`.

### Supervisor (Linux)

[](#supervisor-linux)

`/etc/supervisor/conf.d/fcm-blast.conf` — `numprocs=4` must match the `workers` you pass to `start()`:

```
[program:fcm-blast]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/artisan queue:work redis --queue=fcm-blast --tries=1 --timeout=1800
autostart=true
autorestart=true
stopwaitsecs=1810
user=www-data
numprocs=4
redirect_stderr=true
stdout_logfile=/var/log/fcm-blast.log
stopasgroup=true
killasgroup=true
```

```
sudo supervisorctl reread && sudo supervisorctl update && sudo supervisorctl start fcm-blast:*
```

### macOS (Homebrew supervisor)

[](#macos-homebrew-supervisor)

```
brew install supervisor
# config dir: /opt/homebrew/etc/supervisor.d/ (Apple Silicon) or /usr/local/etc/supervisor.d/ (Intel)
brew services start supervisor
```

Use the same `[program:fcm-blast]` block as above, adjusting `command` to your `artisan` path and `user` to your account.

### Windows (production)

[](#windows-production)

PowerShell `Start-Process` is fine for ad-hoc runs. For a managed service, wrap each `queue:work` in [NSSM](https://nssm.cc/) or run the workers inside WSL/Docker under Supervisor as above.

Monitor
-------

[](#monitor)

```
$status = FcmBlast::status($runId);   // Laith343\FcmBlast\Support\BlastStatus

$status->sent;             // attempts completed (includes retries)
$status->ok;               // 2xx
$status->invalid;          // 404/400 -> InvalidTokenHandler fired
$status->throttled;        // FCM 429/503 retry events (quota) -> watch this for quota limits
$status->transportRetries; // transient transport retry events (timeout/reset) -> watch this for network limits
$status->failed;           // permanent errors / retries exhausted
$status->rps;
$status->avgLatencyMs;
$status->progressPercent;
$status->finished;
$status->stalled;          // running but no worker has reported in ~15s (workers died)
$status->toArray();        // for JSON endpoints
```

> `throttled` vs `transportRetries` tells you *which ceiling* you're hitting: `throttled` climbing = FCM project quota (request more); `transportRetries` climbing = network/connection limits (fewer connections, scale hosts). Both are retried automatically and only become `failed` once `max_retries` is exhausted.

A `Laith343\FcmBlast\Events\FcmBlastCompleted` event fires when a run finishes — listen for it to trigger follow-up work.

How it hits 10k RPS
-------------------

[](#how-it-hits-10k-rps)

- **N parallel queue workers**, each `floor(rate_cap_per_sec / workers)` RPS.
- **Reused curl handle pool + persistent TCP** — no `curl_close` per request, avoids ephemeral-port exhaustion.
- **`kreait` OAuth token cached in your cache store**, refreshed ~10 min before expiry under a lock (no per-request token fetch, no stampede).
- **Paced token-bucket rate limiter** per worker — refills continuously so sends are spread evenly across each second instead of bursting at the window edge. The burst is bounded by `rate_burst` (not by connection count), which keeps you under FCM's sub-second quota even with a large connection pool.
- **Atomic delta counters** flushed every 500 ms (`UPDATE ... SET col = col + delta`) — use Postgres/MySQL for concurrent worker writes.
- **Exponential backoff + in-process retry** for `429`/`503` **and transient transport errors** (timeouts, connection resets, send/recv failures), so network blips self-heal; **404/400 pruning** via your handler.

### Ephemeral port range (one-time host tuning)

[](#ephemeral-port-range-one-time-host-tuning)

At thousands of RPS the OS can run out of ephemeral ports for outbound connections. Widen the range once:

**Windows (admin PowerShell)**

```
netsh int ipv4 set dynamicport tcp start=10000 num=55000
```

**Linux**

```
sudo sysctl -w net.ipv4.ip_local_port_range="10000 65535"
# persist: echo 'net.ipv4.ip_local_port_range = 10000 65535' | sudo tee /etc/sysctl.d/99-fcm-blast.conf
```

**macOS**

```
sudo sysctl -w net.inet.ip.portrange.first=10000
sudo sysctl -w net.inet.ip.portrange.hifirst=10000
```

The reused handle pool keeps sockets alive across requests, so this mainly matters during connection ramp-up and on hosts under other network load.

###  Health Score

40

—

FairBetter than 86% of packages

Maintenance100

Actively maintained with recent releases

Popularity2

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity44

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~0 days

Total

9

Last Release

1d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/87765501?v=4)[LAITH343](/maintainers/LAITH343)[@LAITH343](https://github.com/LAITH343)

---

Top Contributors

[![LAITH343](https://avatars.githubusercontent.com/u/87765501?v=4)](https://github.com/LAITH343 "LAITH343 (10 commits)")

---

Tags

laravelfirebaseFCMpush notificationscurl\_multihigh-throughput

###  Code Quality

TestsPest

### Embed Badge

![Health badge](/badges/laith343-laravel-fcm-blast/health.svg)

```
[![Health](https://phpackages.com/badges/laith343-laravel-fcm-blast/health.svg)](https://phpackages.com/packages/laith343-laravel-fcm-blast)
```

###  Alternatives

[larastan/larastan

Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel

6.4k51.0M7.4k](/packages/larastan-larastan)[laravel/ai

The official AI SDK for Laravel.

9782.1M153](/packages/laravel-ai)[psalm/plugin-laravel

Psalm plugin for Laravel

3325.1M337](/packages/psalm-plugin-laravel)[spatie/laravel-health

Monitor the health of a Laravel application

88011.3M149](/packages/spatie-laravel-health)[laravel/pulse

Laravel Pulse is a real-time application performance monitoring tool and dashboard for your Laravel application.

1.7k14.1M120](/packages/laravel-pulse)[laravel/pennant

A simple, lightweight library for managing feature flags.

58213.1M81](/packages/laravel-pennant)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
