honk / honk
Official PHP client for Honk! Honk! (honk-honk.app): send events from apps, scripts and automations to your phone. Laravel facade, queued sends and notification channel included.
Requires
- php: ^8.3
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
Requires (Dev)
- guzzlehttp/guzzle: ^7.9
- larastan/larastan: ^3.0
- orchestra/testbench: ^11.0
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^12.0
Suggests
- ext-curl: Used by default to talk to Honk! Honk! (or pass any PSR-18 client).
- laravel/framework: Facade, Honk::fake(), queue/defer sends, the notification channel, exception reporting and scheduler hooks (Laravel 13).
Provides
None
Conflicts
- illuminate/support: <13.0
- laravel/framework: <13.0
Replaces
None
README
Your PHP app honks, your phone buzzes. The official PHP client for Honk! Honk!, the inbox that turns events from apps, scripts, cron jobs and CI into calm, grouped push notifications. It works in any PHP 8.3 app and comes with a full Laravel 13 integration.
use Honk\Laravel\Facades\Honk; // The oven sensor smells trouble. Honk::uhoh('kitchen/oven', 'The cake is burning', 'Oven at 260 °C and climbing.'); // Someone got there in time. Honk::phew('kitchen/oven', 'Cake saved', 'Nobody needs to know.');
uhoh() opens a problem and phew() closes it. The shared group key kitchen/oven ties them
together: an oven that keeps panicking adds to the same incident instead of buzzing you every
minute, and phew() closes it with one last push.
No Laravel? Same idea, with a client:
use Honk\Client; use Honk\Message; $honk = Client::fromEnv(); // HONK_URL and HONK_KEY $honk->honk(Message::make('Someone bought 3 rubber chickens.')->title('New order')->beep());
- PHP 8.3+. ext-curl with keep-alive by default, or any PSR-18 client.
- Laravel 13: auto-discovered provider,
Honkfacade,Honk::defer()/Honk::queue(), thehonknotification channel,Honk::fake(), exception reporting, scheduler hooks,php artisan honk:install/honk:testand aphp artisan aboutsection. - Retries with backoff,
Retry-After, a total deadline and an idempotency key on every send, so a retry never creates a duplicate.
The ingestion key (honk_…) is a secret: keep it in .env, never in front-end code or Git.
Install
composer require honk/honk
Create a project and an ingestion key at honk-honk.app. Its Integrations page generates ready-to-paste code for plain PHP and Laravel.
Client::fromEnv() reads HONK_URL, HONK_KEY and the optional HONK_SOURCE,
HONK_ENVIRONMENT and HONK_CHANNEL defaults; new Client(url: …, key: …) takes them
directly (see Options).
The Honk scale
Five levels. The Honk app shows them by their standard names (Info, Success, Warning, Error, Critical); people who prefer horn sounds can switch on the Honk names (Light, Beep, Loud, Long, Blast) with the "Honk names for levels" switch in Settings. The SDK takes both: the canonical values and the Honk names as input aliases, in any case, and always sends the canonical value.
| Level | Honk name | Severity | Client / facade | Builder | Enum |
|---|---|---|---|---|---|
| Info | Light | light (info) |
light($title, $message, $options) |
->light() |
Severity::Light |
| Success | Beep | beep (success) |
beep(…) |
->beep() |
Severity::Beep |
| Warning | Loud | loud (warning) |
loud(…) |
->loud() |
Severity::Loud |
| Error | Long | long (error) |
long(…) |
->long() |
Severity::Long |
| Critical | Blast | blast (critical) |
blast(…) |
->blast() |
Severity::Blast |
->severity('loud'), 'LOUD', 'warning' and Severity::Loud (which is
Severity::Warning) are the same event, also for idempotency. The level alone decides how
loud the push is. info(), success(), warning(), error() and critical()
remain as synonyms; Severity::parse('Blast') returns Severity::Critical.
Laravel 13
composer require honk/honk
php artisan honk:install # publishes config/honk.php, adds HONK_URL / HONK_KEY to .env.example
Put your server and a project ingestion key in .env (never commit it), then check the
connection:
HONK_URL=https://honk.example.com HONK_KEY=honk_xxxxxxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
php artisan honk:test # sends a Light test message and prints its id php artisan about --only=honk # URL, key prefix (never the secret), queue, Context metadata
source defaults to your APP_NAME (slugged) and environment to APP_ENV. The package is
for Laravel 13 only (PHP 8.3+); Composer refuses older Laravel versions.
Recipe: notify me when a customer asks for something
1. A notification on the honk channel. ShouldQueue keeps the customer's request fast;
Laravel 13's queue attributes set the retries:
// app/Notifications/CustomerRequested.php namespace App\Notifications; use App\Models\CustomerRequest; use Honk\Laravel\Notifications\HonkMessage; use Honk\Laravel\Notifications\ToHonk; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Notifications\Notification; use Illuminate\Queue\Attributes\Backoff; use Illuminate\Queue\Attributes\Tries; use Illuminate\Support\Str; #[Tries(5)] #[Backoff(10, 60, 300, 900)] class CustomerRequested extends Notification implements ShouldQueue, ToHonk { use Queueable; public function __construct(public CustomerRequest $request) { $this->afterCommit(); } public function via(object $notifiable): array { return ['honk']; // add 'mail', 'database', … as you like } public function toHonk(object $notifiable): HonkMessage { $r = $this->request; return HonkMessage::create() ->title(Str::limit("New request: {$r->subject}", 150)) ->line("{$r->name} ({$r->company})") ->line(Str::limit($r->body, 2000)) ->light() // a Light message (info) ->category('customers') ->groupKey("requests/{$r->id}") // one group per request ->occurredAt($r->created_at) ->url(route('admin.requests.show', $r)) // https only, shown as "Open link" ->meta('request_id', (string) $r->id); } }
2. Send it where the request is stored:
$request = CustomerRequest::create($validated); $admin->notify(new CustomerRequested($request)); // a User with the Notifiable trait Notification::route('honk', null)->notify(new CustomerRequested($request)); // or without a user
The phone buzzes once per request, follow-ups for the same request update the same group, and
queue retries never create duplicates: the idempotency key is notification-{id}, which
Laravel keeps across retries. examples/laravel-app in the Honk! Honk! repository is this recipe as a
complete Laravel 13 + Inertia + Vue + Wayfinder app.
- Keep
toHonk()deterministic (nonow(); use$r->created_at): a queued retry must send the same payload, otherwise Honk! Honk! answers409 idempotency_conflict. - Links and images must be
https://. WithAPP_URL=http://localhostin development, leave->url()/->imageUrl()out or guard them withstr_starts_with($link, 'https://'). - Routing: without
routeNotificationForHonk()the configured key is used. Return another ingestion key, an array of config overrides (['key' => …, 'defaults' => [...]]), aHonk\Client, orfalseto skip Honk! Honk! for that notifiable.
Send now, after the response, or from the queue
use Honk\Laravel\Facades\Honk; use Honk\Message; Honk::defer()->beep('New order', "{$order->email} paid {$order->total} €"); // after the response Honk::queue(Message::make('Imported 1 204 rows')->beep()->channel('imports')); // a queued job Honk::loud('Disk 91%', '/var on app-01', ['groupKey' => 'disk/app-01/var']); // right now
| Runs | Use it for | |
|---|---|---|
Honk::defer() |
in this process, after the HTTP response is sent (Laravel's defer(); after the command or job in the console and on queues) |
web requests where a lost honk is acceptable if the process dies; no worker needed. Skipped when the request fails (4xx/5xx) unless Honk::defer(always: true) |
Honk::queue() |
in a queue worker (SendHonkMessage, #[Tries(5)], #[Backoff(10, 60, 300, 900)]) |
anything that must arrive: survives restarts and retries with the same key; a daily quota releases the job until the reset |
Honk::honk(), Honk::light() … |
right now, retries included (up to honk.deadline) |
jobs, commands, the scheduler |
defer() and queue() check right away that there is a message or a title, so that mistake
surfaces in your code, not later. queue() returns the job; the connection and queue come from
HONK_QUEUE_CONNECTION / HONK_QUEUE (registered with Laravel 13's Queue::route()).
Testing: Honk::fake()
use Honk\Laravel\Facades\Honk; use Honk\Message; use Honk\Severity; Honk::fake(); $this->post(route('quote.store'), $data); Honk::assertSent(fn (Message $m) => $m->groupKey === 'requests/1' && $m->severity === Severity::Light); Honk::assertSentTimes(1); Honk::assertNotSent(fn (Message $m) => $m->severity === Severity::Blast); Honk::assertQueued(fn (Message $m, string $idempotencyKey) => $m->channel === 'imports'); Honk::assertNothingSent(); // also assertNothingQueued(), assertNothingOutgoing()
The fake records honk(), uhoh(), phew(), the horn helpers, defer() and
notification-channel sends as sent, and queue() as queued; nothing touches the network.
It checks like the real client (no message and no title still throws), applies the defaults and
Context metadata, and records the severity as a Severity case whatever spelling was used.
Callbacks receive the message and its idempotency key. With Notification::fake() instead,
toHonk() is never called.
Exceptions
One line in bootstrap/app.php:
->withExceptions(function (Exceptions $exceptions): void { $exceptions->report(Honk::reportable()); })
Every reported exception becomes a Long message: a problem whose group_key is
exceptions/<Class>@<file>:<line>, so repeats of one failure are a single incident. At most
one honk per group per 5 minutes (honk.exceptions.throttle, cache-backed), sent after the
response (honk.exceptions.mode: defer, sync or queue). Pick another severity or window
per app: Honk::reportable(severity: 'blast', throttleSeconds: 60); list classes to skip in
honk.exceptions.ignore. It never honks about the SDK's own failures, does nothing while
HONK_URL / HONK_KEY are missing (local development, CI), and never throws. Exception
messages are sent as they are (truncated): do not put personal data in them.
Scheduler
// routes/console.php Schedule::command('backup:run')->daily()->honkOnFailure(); // problem on failure, recovery once it works again Schedule::command('reports:send')->hourly()->honkOnSuccess(); // a Beep message after every successful run
honkOnFailure(?string $groupKey = null, string $severity = 'long') sends a problem with the
exit code and the output tail, grouped per task (schedule/<command or description>), and a
recovery the next time the task succeeds, only after a failure: the failing state lives in the
cache, so a healthy task never pings (a recovery without an open problem would still notify).
Context (opt-in)
With HONK_CONTEXT=true, the keys listed in honk.context.keys (default request_id,
trace_id, route) are copied from Laravel's Context into each message's metadata. Only
visible context is read, never hidden context; the message's own metadata wins; the 16-key
limit is respected. Nothing is copied by default, and the list should never contain personal
data (emails, names, IP addresses). Add the values in a middleware, for example:
Context::add('trace_id', (string) Str::uuid()); Context::add('route', $request->route()?->getName());
Context also travels with queued jobs, so queued honks carry the values of the request that dispatched them.
Events and container
Honk\Laravel\Events\HonkSent(message,Acceptedwith itswarnings, idempotency key,via) andHonkFailed(message, exception, idempotency key,via) fire for every message;viais'send'(sent right away),'defer','queue'or'notification'.HonkManager(the facade root) is a#[Singleton]that receives#[Config('honk')]; inject it, orHonk\Client, anywhere. Config is read when Honk! Honk! is first used.
Grouping in three lines
Messages with the same groupKey (per project, environment, source and channel) form one
group: the first one pushes, repeats update it calmly instead of buzzing again. Use one key
per customer request (requests/{$id}), and a shared key only for repeats of the same problem
(queue/failed-jobs). The group key is optional: without one, a message groups by its title.
Sending
$client->honk(Message|array $message, ?string $idempotencyKey = null): Accepted // id, duplicate, receivedAt, warnings
honk(), uhoh() and phew() arrived in 0.3.0; send, problem and recovery still work
and send exactly the same request.
The essentials:
| Field (builder method) | Notes |
|---|---|
message |
the text; line breaks allowed (->line() appends one) |
title |
one line; default: the first line of the message. A message or a title is required, nothing else |
severity (->light() … ->blast()) |
the level, and the level alone decides how loud the push is: light beep loud long blast, a Severity case, or the canonical names, any case; default light |
groupKey |
optional; ties repeats and a problem to its recovery |
eventType |
event problem recovery (what uhoh() / phew() set) |
The other fields (priority, category, source, environment, channel, occurredAt,
url, imageUrl, actions, metadata, ttlSeconds, sourceSequence) mirror
POST /v1/messages in camelCase; occurredAt takes a DateTimeInterface (Carbon works). The
SDK does not check their contents: the server does, and it delivers the message even when it
has to correct one (see Warnings).
Arrays use the same camelCase keys (['message' => …, 'groupKey' => …]); snake_case keys
are rejected with a hint. Null and empty optional fields (and empty actions) are omitted. A
returned Accepted means Honk! Honk! durably stored the message (202), not that a push was
delivered or read.
Helpers ($options takes any field in camelCase plus idempotencyKey):
$client->loud('Disk 91%', '/var on app-01', ['groupKey' => 'disk/app-01/var']); $client->light($title, $message, $options); $client->beep(...); $client->long(...); $client->blast(...); $client->uhoh('db/backup', 'Backup failed', 'pg_dump exited with 1'); // opens a problem; a Long message by default $client->phew('db/backup', 'Backup OK', 'pg_dump finished in 41 s'); // closes it; a Beep message by default $client->phew(null, 'Backup OK', 'pg_dump finished'); // no group key: matched by the title
Buttons (actions)
Up to three buttons on the message, in display order: reply to the customer, call them, open the order.
$client->honk(Message::make('Maria Tudor (Acme) asked for a quote: online shop, 40 products') ->title('New request: online shop quote') ->category('customers') ->groupKey('requests/4812') ->action('Reply', 'mailto:maria@acme.example?subject=' . rawurlencode('Your quote')) ->action('Call Maria', 'tel:+15550134'));
title: a short label, one line, shown as sent.url:https://,mailto:(optionally?subject=…&body=…, percent-encoded withrawurlencode()),tel:orsms:. The server leaves out a button it cannot use and says so in the warnings (actions[1].url ignored); the message is still delivered.- Honk! Honk! never opens or fetches them; the app does when you tap one. They appear on the message in the app and the web inbox, and on iPhone notifications that show the message text.
- The same works in
toHonk()(HonkMessage::create()->action(…)), in arrays ('actions' => [['title' => 'Call Maria', 'url' => 'tel:+15550134']]), withHonk::queue()andHonk::defer().->actions([...])replaces the list.
Options
new Client( url: 'https://honk.example.com', key: 'honk_…', timeoutMs: 5000, // per attempt retries: 4, // after the first attempt deadlineMs: 30000, // total, waits included defaults: ['source' => 'billing', 'environment' => 'production', 'channel' => 'payments'], httpClient: $psr18, // optional PSR-18 client; with requestFactory/streamFactory (auto with Guzzle) onWarnings: fn (Accepted $a) => error_log(implode('; ', $a->warnings)), // optional, see Warnings );
With a PSR-18 client, configure its own timeout and disable redirects
(Guzzle: new GuzzleHttp\Client(['timeout' => 5, 'allow_redirects' => false])); the SDK
still enforces the total deadline.
Warnings
The server delivers first: an alert is never lost to an optional field. When it has to correct
something (shorten a long title, leave out an http:// link or a button it cannot use, move a
TTL into range, lower urgent for a key that may not send it), it accepts the message and says
what it did. Accepted::$warnings lists them ([] when nothing was corrected):
$accepted = $honk->honk(Message::make('Disk 91%')->title($veryLongTitle)->url('http://grafana.local')); foreach ($accepted->warnings as $w) { // Honk\Warning echo $w; // "title truncated: shortened to 160 characters" // $w->field ("title", "metadata.order", "actions[1].url"), $w->code, $w->message } $accepted->hasWarnings();
$w->code is truncated, normalized, ignored, clamped, downgraded or hashed
(Warning::TRUNCATED …). Warnings are never exceptions. To log them in one place, pass
onWarnings: fn (Accepted $a) => … to the client; it runs once per send that has warnings, and
anything it throws is ignored. In Laravel each warning is logged at warning level (with the
field, code, message id and idempotency key) for every way of sending; turn that off with
HONK_LOG_WARNINGS=false (honk.log_warnings). HonkSent::$accepted->warnings has them too,
and php artisan honk:test prints them.
Retries and idempotency, guaranteed
- Every send carries an
Idempotency-Key: yours, or a fresh UUIDv7. The same key is reused on every retry. Within 24 h Honk! Honk! answers a replay with the original id andduplicate: true, so a lost response never creates a second message. - Only network errors, timeouts,
429and5xxare retried, with exponential backoff and full jitter (random(0, min(8 s, 0.5 s·2ⁿ))), never sooner than the server'sRetry-After. - Everything stops at
deadlineMs: if the next wait would cross it (for example a daily quota that resets at midnight), the exception is thrown at once withretryAfter. 4xxother than429are never retried: fix the request instead.- Short per-attempt timeouts, one keep-alive connection per client (reuse the client; Laravel keeps one per worker).
- Use a stable key of your own (
request-4812) to stay duplicate-free across processes and job retries. Reusing a key with a different payload is a conflict.
Errors
All exceptions extend Honk\Exception\HonkException and expose status, errorCode,
requestId, idempotencyKey, attempts, retryAfter and isRetryable(). The message says
it all, e.g. Honk 422 validation_failed: Request validation failed (event_type invalid_enum: must be one of event, problem, recovery) [request req_01k6…].
The server refuses only what it cannot deliver: a bad or missing key, invalid JSON, a body over
16 KiB, no message and no title, an unknown top-level field, an invalid eventType.
| Exception | When | What to do |
|---|---|---|
ValidationException |
400/413/415/422, or locally (local === true) for no message and no title or an invalid idempotency key; fields lists each field with its code and message |
fix the message |
AuthException |
401 invalid_key, 403 project_suspended, workspace_suspended |
fix the key |
QuotaException |
429 quota_exceeded (daily, resets at UTC midnight) or rate_limited, after retries |
retry after retryAfter seconds |
ConflictException |
409 idempotency_conflict: same key, different payload |
use a new key or the original payload |
NetworkException |
unreachable on every attempt | retry later, same key |
TimeoutException (a NetworkException) |
attempts timed out; the message may or may not be stored | retry later, same key |
ServerException |
5xx on every attempt |
retry later, same key |
use Honk\Exception\HonkException; use Honk\Exception\ValidationException; try { $honk->honk($message, "order-{$order->id}-failed"); } catch (ValidationException $e) { report($e); // a bug: $e->fields (field, code, message) says what to fix } catch (HonkException $e) { if (!$e->isRetryable()) { throw $e; } // retry later with $e->idempotencyKey, after $e->retryAfter seconds if set }
Development
composer install composer test # unit tests (scriptable mock server) + Laravel 13 tests (Orchestra Testbench 11) composer analyse # PHPStan level max with Larastan HONK_URL=… HONK_KEY=… composer test:integration # against a real server (use a test project's key)
The version lives in Honk\Client::VERSION (also the User-Agent). Releases: push a tag
vX.Y.Z matching it; Packagist picks the tag up (see CHANGELOG.md).
Links
- honk-honk.app: the Honk! Honk! inbox (web, iPhone).
- Other SDKs: Node.js, Go + CLI, Swift, Kotlin / Java.
MIT License.