fanout/gripcontrol
A PHP library for building GRIP-enabled realtime applications with Fanout. It helps parse and generate GRIP headers, hold HTTP responses, publish messages to channels, and handle WebSocket-over-HTTP, simplifying PubSub and realtime API endpoints.
fanout/gripcontrol is the PHP implementation of GRIP (Generic Realtime Intermediary Protocol). You use it when your Laravel app sits behind a GRIP proxy (Pushpin, Fanout Cloud/Fastly Fanout) and needs to do one of three things:
Grip-Hold / Grip-Channel headers)./publish/ endpoint.Install and pin it (the package is archived, so lock the version):
composer require fanout/gripcontrol:^1.0
The classes live in the Grip\ namespace. Wire a singleton in a service provider, mirroring the Pushpin-style config array the library expects:
// config/grip.php
return [
'clients' => [
[
'control_uri' => env('GRIP_CONTROL_URI', 'http://127.0.0.1:5561/'),
'control_iss' => env('GRIP_CONTROL_ISS'),
'key' => env('GRIP_KEY'),
],
],
'sig_key' => env('GRIP_SIG_KEY'),
];
// app/Providers/GripServiceProvider.php
use Grip\GripPubControl;
public function register(): void
{
$this->app->singleton(GripPubControl::class, fn () =>
new GripPubControl(config('grip.clients'))
);
}
First use case: a broadcast endpoint. A controller publishes a WebSocket message to a channel, and any client subscribed through Pushpin receives it:
use Grip\GripPubControl;
use Grip\WebSocketMessageFormat;
public function __invoke(GripPubControl $pub, string $room)
{
$pub->publish("room-{$room}", new WebSocketMessageFormat(
json_encode(['type' => 'ping', 'at' => now()->toIso8601String()])
));
return response()->noContent();
}
Look first at the library's README and the Pushpin docs on the three headers — everything else in the package is plumbing around those.
Subscribe-on-request via middleware. The core pattern is: your normal Laravel response, plus GRIP headers that instruct the proxy to hold it. Do this in a route middleware or a dedicated controller rather than scattering headers in every action:
public function handle($request, Closure $next)
{
$response = $next($request);
if ($request->attributes->get('grip.subscribe')) {
$response->headers->set('Grip-Hold', 'stream');
$response->headers->set('Grip-Channel', implode(',', $request->attributes->get('grip.channels')));
}
return $response;
}
If your release ships the instruct helpers (GripInstruct and friends), build the header set there and dump it onto the response — it saves you from hand-formatting the comma-separated channel list.
WebSocket-over-HTTP endpoint. When Pushpin proxies WS traffic, it POSTs application/websocket-events bodies with a Grip-Sig header. Parse the context, loop the events, and return the response the library assembles:
use Grip\WebSocketContext;
public function ws(Request $request)
{
$ctx = WebSocketContext::from_env($request); // older builds: from_env()
// ... validate_sig($request->header('Grip-Sig'), config('grip.sig_key'))
while ($event = $ctx->next_event()) {
if ($event->type === 'TEXT') {
$ctx->send($event->content); // echo back
}
}
return response($ctx->get_body(), 200, $ctx->get_headers());
}
Treat that route as withoutMiddleware(['web', 'csrf']) — the proxy, not a browser cookie, is the caller.
Offload publishing to the queue. Publishing is an HTTP call to the proxy. Put it in a job or listener so a slow or down proxy doesn't stall a user request:
class BroadcastToRoom implements ShouldQueue
{
public function handle(GripPubControl $pub): void
{
$pub->publish("room-{$this->room}", new WebSocketMessageFormat($this->payload));
}
}
Fire it from a domain event listener, keeping the "publish" concern out of controllers.
Choose the format per transport. WebSocketMessageFormat for WS frames, HttpStreamFormat for chunked streaming/SSE-ish clients, HttpResponseFormat for long-poll bodies. Publish several at once with publish_formats($channel, [...]) when one channel serves mixed clients.
Testing. Point GRIP_CONTROL_URI at a local stub (or Http::fake() around a thin wrapper you own) and assert on the job/format objects rather than on real pushes.
Grip-Hold reaches the browser instead of Pushpin, requests hang or behave bizarrely. Verify the proxy is actually in front in every environment, including local Docker.Grip-Sig. The proxy signs requests; if you skip validate_sig, anyone can post a forged WebSocket-events body or instruct your app. Use the same key Pushpin holds.Grip-Channel values. Symfony's HeaderBag collapses array sets with commas; don't call headers->set() twice and expect two header lines. Set one comma-separated string, or use headers->set($name, $values, false) where you need raw control.Content-Length on held/streamed responses, and set Cache-Control: no-cache — otherwise intermediaries buffer and the hold never appears to the client.204 or an empty 200 without a Grip-Channel header silently unsubscribes the client.getContent() is cached by Laravel, but any earlier middleware that consumed it will break parsing.PubControlClient returns a boolean rather than throwing; wrap it and log. Combined with a queued job, set retries/backoff so a proxy restart doesn't drop messages.App\Realtime\Grip that composes GripPubControl and the format classes — that's your seam for mocking in tests and for the eventual migration off this package.GripPubControl::from_config_file() expects a JSON array in Pushpin's config shape. Prefer the constructor + Laravel config so php artisan config:cache works.How can I help you explore Laravel packages today?