Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Lighthouse Ws Laravel Package

nuwave/lighthouse-ws

Adds WebSocket subscription support to Laravel Lighthouse GraphQL, enabling real-time updates and client subscriptions over websockets in Laravel applications.

View on GitHub
Deep Wiki
Context7

Getting Started

Prerequisites, in order of importance. Do not start with composer require. Start by checking your Lighthouse version:

composer show nuwave/lighthouse

This package's last release is 2018-05-05 and the repo is archived. That places it in the Lighthouse v2 era, well before the current native @subscription directive and lighthouse:subscription generator existed in modern Lighthouse. If you are on Lighthouse v5/v6, the functionality you want is likely already in the framework and this package is dead weight. Confirm the constraint before spending any time on setup.

Assuming you are on a compatible Lighthouse, the minimal path is:

  1. Install pinned, not with a caret range:
    composer require nuwave/lighthouse-ws --with-all-dependencies
    
    Then edit composer.json to an exact version — there is no changelog to diff against on upgrade.
  2. Register the provider. 2018-era packages are not reliable about extra.laravel.providers auto-discovery, so register manually in config/app.php (or bootstrap/providers.php on Laravel 11+):
    Nuwave\LighthouseWs\LighthouseWsServiceProvider::class,
    
    Verify the FQCN against vendor/nuwave/lighthouse-ws/composer.json and src/ — the docs do not state it.
  3. Publish and read the config:
    php artisan vendor:publish --provider="Nuwave\LighthouseWs\LighthouseWsServiceProvider"
    
    If that fails, run bare php artisan vendor:publish and pick the tag from the interactive list.
  4. Configure broadcasting in .env — the package does not own the transport, Laravel's broadcasting layer does:
    BROADCAST_CONNECTION=reverb   # or pusher, or laravel-websockets
    REVERB_APP_ID=...
    REVERB_APP_KEY=...
    REVERB_APP_SECRET=...
    REVERB_HOST=localhost
    REVERB_PORT=8080
    REVERB_SCHEME=http
    
  5. Run the socket server in its own terminal — PHP cannot hold WebSocket connections, so this must be Reverb / Pusher / laravel-websockets:
    php artisan reverb:start --debug
    

Where to look first. Read vendor/nuwave/lighthouse-ws/src/ top to bottom — with 12 stars and an archived repo, the source is the documentation. Prioritize the service provider and the directive/manipulator class that resolves @subscription fields; that tells you what payload shape the client will receive. Then read the published config file, which is the only supported extension surface.

First use case: a single public subscription on a model you already broadcast, wired end-to-end and observed in the socket server's debug log. Do not start with an authenticated private channel; you will conflate auth failures with wiring failures.

type Subscription {
    postUpdated(id: ID!): Post @subscription(class: "App\\GraphQL\\Subscriptions\\PostUpdated")
}
namespace App\GraphQL\Subscriptions;

use App\Models\Post;
use Illuminate\Broadcasting\Channel;

class PostUpdated
{
    public function __construct(public Post $post) {}

    public function broadcastOn(): Channel
    {
        return new Channel('posts');
    }

    public function broadcastWith(): array
    {
        return ['postUpdated' => $this->post->toArray()];
    }
}

Fire it from tinker with event(new PostUpdated(Post::first())) and watch for the frame in reverb:start --debug. Only once that works should you touch the client.

Implementation Patterns

Broadcast on model events, not in resolvers. The idiomatic Laravel pattern is to pair the subscription class with a model observer or $dispatchesEvents, so any write — GraphQL mutation, artisan command, queue job, tinker — produces the same notification. Putting broadcast() inside a mutation resolver silently skips every non-GraphQL write path and will bite you the first time a sync job touches the table.

class Post extends Model
{
    protected $dispatchesEvents = [
        'updated' => PostUpdated::class,
    ];
}

Implement ShouldBroadcast (or ShouldBroadcastNow for latency-sensitive fields) on the event class. Queued broadcasting is the default and the usual reason "nothing arrives."

Payload shape is the contract, not the GraphQL selection. Unlike queries, a subscription payload is whatever the event's broadcastWith() returns. There is no field-level resolution against the client's selection set in this generation of the package. That means you must return exactly the keys the schema declares, and you must decide up front whether Post fields resolve from the payload or trigger a follow-up fetch. Keep payloads narrow:

public function broadcastWith(): array
{
    return [
        'postUpdated' => [
            'id' => $this->post->id,
            'title' => $this->post->title,
            'updatedAt' => $this->post->updated_at->toIso8601String(),
        ],
    ];
}

Authorization belongs in two places, and both are yours to write. The GraphQL-side check is the usual Lighthouse middleware:

type Subscription {
    orderStatusChanged(id: ID!): Order
        @subscription(class: "App\\GraphQL\\Subscriptions\\OrderStatusChanged")
        @guard(with: ["api"])
        @can(ability: "view", find: "id")
}

The transport-side check goes in routes/channels.php, because Laravel's broadcasting layer authorizes channel joins independently of the GraphQL execution:

Broadcast::channel('orders.{orderId}', function ($user, $orderId) {
    return $user->can('view', Order::find($orderId));
});

If these two disagree you get a subscription that connects but never receives, or one that receives other users' data.

Client integration: match the protocol, not the library. In 2018 the ecosystem spoke Apollo's legacy subscriptions-transport-ws. Modern clients (graphql-ws npm package, Apollo Client 3.7+, urql) default to the newer graphql-transport-ws subprotocol and will fail the handshake with an immediate socket close. Pick your client deliberately:

// Modern client, if you add a compatibility layer / adapter server-side
import { createClient } from 'graphql-ws';
const client = createClient({
  url: 'ws://localhost:8080/graphql',
  connectionParams: { Authorization: `Bearer ${token}` },
});

Pass the token in connectionParams, never rely on cookies. PHP-FPM session cookies are not automatically replayed on a WebSocket upgrade. Your connection-init handler must read the token, authenticate the guard, and stash the user for the lifetime of the connection.

Testing. Keep the socket layer out of PHPUnit. Test the event class directly and use Laravel's fakes for the broadcast:

Event::fake([PostUpdated::class]);

$this->graphQLMutation(/* updatePost */);

Event::assertDispatched(PostUpdated::class, fn ($e) => $e->post->id === $post->id);

Then test broadcastWith() output against the schema shape as a plain unit test — that is where payload/schema drift gets caught cheaply, rather than in a browser.

Rollout pattern. Given the archived status, wrap subscription fields behind a config flag you control, so disabling real-time is a .env change rather than a schema edit and redeploy:

// config/lighthouse-ws.php (published), then:
if (! config('features.realtime')) {
    // register a no-op directive or omit the field from the schema
}

Gotchas and Tips

Archived means "as-is." No security patches, no compatibility fixes for new Laravel majors, no protocol updates. Everything below is something you may have to fix yourself, in a fork, forever.

The graphql-ws compatibility claim is anachronistic. The description advertises compatibility with graphql-ws/Apollo, but a 2018 package predates the graphql-transport-ws subprotocol that the graphql-ws npm package implements today. Assume legacy subscriptions-transport-ws wire format until you have watched the handshake frames in devtools. This is the single most likely source of a day-one "it just doesn't connect" bug.

BROADCAST_CONNECTION=log swallows everything silently. It is the default in fresh Laravel installs. Events fire, nothing errors, no client receives anything. Set the real driver before your first debugging session, and check storage/logs/laravel.log as a quick confirmation the event actually dispatched.

Queued broadcast + no worker = no messages. ShouldBroadcast pushes onto the queue. php artisan queue:work must be running. Use ShouldBroadcastNow during initial bring-up so you eliminate one variable.

config:cache breaks things that worked locally. A 2018 provider may read config at registration time; cached config in production versus uncached locally is a classic divergence. If it works in dev and not in prod, run php artisan config:clear and compare.

Two independent authorization checks. @guard/@can on the schema field and Broadcast::channel() in routes/channels.php are separate code paths with separate guards. Mismatched guards (api JWT vs web session) produce subscriptions that authorize in GraphQL but get rejected on channel join. Log both sides.

N+1 inside the broadcast payload. Relation access in broadcastWith() runs once per notification per listener. Eager-load in the event constructor ($post->load('author')) so the payload is built from memory, not from queries fired inside the broadcast cycle.

Channel naming collisions. Public Channel vs PrivateChannel vs PresenceChannel changes the auth semantics and the prefix the client must subscribe to (private-, presence-). Pick one convention per resource and stick to it; the naming mismatch is invisible until you check the Pusher/Reverb debug console.

Debugging is a three-layer binary search. Isolate in this order: (1) does the event dispatch? — Event::fake() test or BROADCAST_CONNECTION=log; (2) does the broadcast server see it? — Reverb's --debug output, or the Pusher debug console; (3) does the client receive the frame? — devtools → WS → frames. Skipping straight to the client is how people lose an afternoon.

Don't try to serve sockets from PHP-FPM or artisan serve. Reverb/Pusher/laravel-websockets exist precisely because a PHP request lifecycle cannot hold connections. In Docker, expose the Reverb port and set REVERB_HOST to a hostname the browser can resolve, not the container name — a very common "works in curl, not in the app" failure.

Extension points are thin, so plan the fork. Read src/ for interfaces before committing to customization. If you must change behavior, fork and pin the fork in composer.json via a VCS repository, and note that you now own security patching of the dependency graph.

Keep the integration reversible. Wrap the whole thing behind an interface in your own code (RealtimeNotifier) with one adapter that uses this package. When you migrate to Lighthouse's native subscription support — which is the realistic end state for an archived 2018 package — you swap one adapter instead of rewriting every mutation.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource