revolution/laravel-nostr
Laravel package for working with Nostr: generate and manage keypairs, create and sign events, publish to relays, verify signatures, and handle NIP-05 identifiers. Provides facades and configuration for adding decentralized social features to Laravel apps.
Install and publish the config.
composer require revolution/laravel-nostr
php artisan vendor:publish --tag=nostr-config
The package is a thin Laravel wrapper around a PHP Nostr client. It auto-registers a Nostr facade and ships a single config file, so there is very little surface area to learn.
Configure keys and relays. Everything flows through two env values:
NOSTR_SECRET_KEY=64_hex_chars
NOSTR_PUBLIC_KEY=64_hex_chars # derived from secret if omitted
NOSTR_RELAYS=wss://relay.damus.io,wss://nos.lol
config/nostr.php maps these into a relay array plus a default driver. Clear/publish config before testing, and remember php artisan config:cache if you run it in production.
First use case — publish a text note. This is the "hello world" and the fastest way to confirm signing, relay connectivity, and key parsing all work end to end:
use Revolution\Nostr\Facades\Nostr;
$event = Nostr::event()->create(content: 'Hello from Laravel');
$result = Nostr::event()->publish($event);
Where to look first. Read vendor/revolution/laravel-nostr/src/ in this order: the facade (it lists every driver method), the service provider (what gets bound), then config/nostr.php. The package is small enough to read in ten minutes — do that before trusting the README, because the driver method names are the real API contract.
Treat the facade as a driver registry, not a client. Methods like Nostr::event(), Nostr::text(), Nostr::relay(), and Nostr::directMessage() each return a handler scoped to one NIP. Build an event with the handler, then hand it to publish(). Keep those two steps separate — the intermediate event object is what you persist, log, and retry.
Key management is the real design decision. For single-tenant apps, the env secret is fine. For multi-user apps, store a per-user keypair encrypted in the database (encrypted cast) and resolve it per request rather than relying on the global config. Never let the secret key leave the server; the public key is the only thing that should ever hit a frontend.
Publish through a queued job. Relay round-trips are slow and flaky. Wrap publishing in a ShouldQueue job with $tries = 3 and a backoff; the job holds the signed event array, not the content, so retries are idempotent (event IDs are deterministic).
class PublishNote implements ShouldQueue
{
use Queueable, SerializesModels;
public int $tries = 3;
public array $backoff = [5, 30, 120];
public function __construct(public array $event) {}
public function handle(): void
{
$results = Nostr::event()->publish($this->event);
// log per-relay results; a 200 does not mean the relay stored it
}
}
Fan out to multiple relays, tolerate partial failure. Most implementations return a per-relay response map. Iterate it, log failures, and treat "at least one relay accepted" as success. Do not throw on a single relay timeout — that's normal in Nostr.
Reading: filters, not queries. Subscribe/fetch calls take NIP-01 filters (kinds, authors, since, until, limit, #e, #p tags). Build a small repository class around these so your controllers and Livewire components never construct raw filter arrays. Store ingested events in a local events table keyed by the event ID (id is a sha256 hash — a natural primary key and free idempotency).
A custom Notification channel is the cleanest integration. If you're adding Nostr to an existing Laravel app, a NostrChannel that maps toNostr() on the notification onto a kind-1 event keeps the rest of the app ignorant of the protocol. Same pattern works for an Event/Listener pair on domain events.
Testing. Fake the relay layer with Http::fake() if the driver is HTTP-based; otherwise bind a stub implementation into the container in your test case. Freeze time with Carbon::setTestNow() — event timestamps and filter windows are second-precision and tests will drift otherwise.
nsec1… form users export from Damus/Amethyst. Store the hex form server-side and convert at the boundary if you accept user input. A mismatched format fails at signing time, not at config time — the error is easy to miss.wss:// in production, no trailing slash, no query string. A relay that accepts on one path prefix and rejects on another will look like a signature bug.verify() on ingested events before persisting; it's cheap and it's the whole security model.directMessage() via NIP-04, treat it as deprecated and check whether NIP-44 is available. Don't build new features on the old path.max_execution_time. Use an Artisan command under Supervisor, or a horizon queue worker with a long timeout, and reconnect with exponential backoff on close.config:cache will serialize the result once and ignore env changes on deploy. Prefer explicit explode in the config file at load time."1.x.y") and vendor the source if it becomes load-bearing. Read the diff before every minor upgrade.Nostr::extend() (or rebinding the driver contract in a service provider) is the supported way to swap the underlying client or add a relay pool implementation — go there rather than subclassing the facade.How can I help you explore Laravel packages today?