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

Discourse Php Laravel Package

cviebrock/discourse-php

A PHP client for the Discourse forum API that helps PHP and Laravel applications manage users, groups, topics, and notifications. It simplifies integrating community features, syncing accounts, and automating common Discourse tasks.

View on GitHub
Deep Wiki
Context7

Getting Started

The package is a small, framework-agnostic library with two pieces you'll actually use:

  • Cviebrock\DiscoursePHP\SSOHelper — builds and verifies the Discourse SSO payload (HMAC-SHA256 signature, Base64 encoding, nonce handling).
  • Cviebrock\DiscoursePHP\API\DiscourseAPI — a thin Guzzle wrapper around Discourse's admin/user REST API.

Install it and, because there is no Laravel service provider, bind it yourself:

composer require cviebrock/discourse-php
// app/Providers/DiscourseServiceProvider.php
public function register(): void
{
    $this->app->singleton(SSOHelper::class, function () {
        $sso = new SSOHelper();
        $sso->setSecret(config('services.discourse.sso_secret'));
        $sso->setNonceRequired(true);
        $sso->setNonceStore(
            fn ($nonce) => Cache::get("discourse:sso:{$nonce}"),
            fn ($nonce) => Cache::put("discourse:sso:{$nonce}", true, now()->addMinutes(10)),
            fn ($nonce) => Cache::has("discourse:sso:{$nonce}"),
            fn ($nonce) => Cache::forget("discourse:sso:{$nonce}"),
        );

        return $sso;
    });
}

On the Discourse side (Admin → Settings → Login): enable enable_sso, set sso_url to your app's endpoint (HTTPS, publicly reachable — use ngrok locally), and copy sso_secret into .env as DISCOURSE_SSO_SECRET.

First use case: login only. Don't touch the API client until a user can round-trip from Discourse → your app → back into Discourse. One route, one controller:

// routes/web.php
Route::get('/discourse/sso', [DiscourseSsoController::class, 'sso'])
    ->name('discourse.sso');
public function sso(Request $request, SSOHelper $sso)
{
    $payload = $request->query('sso');
    $sig     = $request->query('sig');

    if (! $sso->validateSignature($payload, $sig)) {
        abort(400, 'Bad SSO signature');
    }

    $data = $sso->getUserFromPayload($payload); // ['nonce' => ..., 'return_sso_url' => ...]

    if (! $sso->validateNonce($data['nonce'])) {
        abort(400, 'Bad or expired nonce');
    }

    if (! Auth::check()) {
        $request->session()->put('discourse.sso', compact('payload', 'sig'));
        return redirect()->route('login');
    }

    $user = Auth::user();

    $response = $sso->getSignInString([
        'nonce'       => $data['nonce'],
        'external_id' => (string) $user->id,      // must be stable forever
        'email'       => $user->email,
        'username'    => $user->discourse_username,
        'name'        => $user->name,
        'avatar_url'  => $user->avatar_url,
    ]);

    return redirect($sso->getReturnURL($response));
}

The helper owns the Base64/HMAC/URL-encoding dance in getSignInString() + getReturnURL(). Don't hand-roll it.

Read src/SSOHelper.php before you start — it's roughly 200 lines and is the real documentation. Confirm the exact names of the signature-validation and nonce-store methods in your installed version (the README and source drift slightly across releases).

Implementation Patterns

Keep the SSO endpoint stateless. The signed sso/sig pair is self-contained; the only server state is the nonce. Persist the nonce in the cache, not the session, so it survives multi-server deployments. Validate and delete the nonce in the same request.

Preserve the return path on login redirect. If the SSO request arrives while the user is logged out, stash sso/sig in the session (or use return_to) and redirect to login. Do not re-fetch a new SSO request from Discourse.

Treat external_id as the identity. Use your immutable primary key, never email or username. If external_id changes, Discourse creates a duplicate account and you'll be cleaning it up manually. Let email/username/name sync on every login — that's the intended behavior.

Wrap the package behind your own interface. The package is lightly maintained (66 stars, zero dependents), and the SSO protocol is small enough to reimplement in ~30 lines of HMAC if you ever need to. A DiscourseSso adapter interface with one implementation that delegates to SSOHelper costs little and buys insurance.

Bind and inject the API client as a singleton, and queue every write:

$this->app->singleton(DiscourseAPI::class, fn () => tap(
    new DiscourseAPI(config('services.discourse.url')),
    function (DiscourseAPI $api) {
        $api->apiKey  = config('services.discourse.api_key');
        $api->apiUser = config('services.discourse.api_user'); // an admin or the system user
    }
));

Provisioning pattern. Create users lazily rather than mirroring on registration — but if you do create on registration, wrap it in a job and tolerate "already exists" responses:

public function handle(DiscourseAPI $api): void
{
    try {
        $api->createUser($this->user->name, $this->user->email, $this->user->name, $this->user->discourse_password);
        $api->activateUser($this->user->discourse_id);
    } catch (RequestException $e) {
        if ($e->getResponse()->getStatusCode() !== 422) {
            throw $e;
        }
    }
}

Group sync. Use the SSO payload's add_groups / remove_groups keys (comma-separated group slugs) for users who log in regularly, but reconcile in both directions with a scheduled job for everyone else. Roles-as-groups is the most reliable way to map Laravel permissions onto Discourse categories without a custom plugin.

Logout. Discourse SSO has no single-logout handshake. If your app logs out, call the API's logOut($discourseUserId) (requires an appropriately scoped key) or accept that the Discourse session persists until it expires.

Observability. Log the nonce, external_id, and the HMAC result on failures — signature mismatches are almost always a secret or encoding problem, and the log line tells you which.

Gotchas and Tips

  • A trailing newline or space in sso_secret is the #1 cause of "invalid signature." Read it with trim(config(...)) if it comes from an env file you don't control.
  • Double URL-decoding. $_GET/Request::query() already decodes once. Passing the payload through urldecode() again corrupts the Base64 and produces confusing signature failures.
  • The signature is over the Base64 string, not the decoded query string. If you ever reimplement, sign what Discourse sent you verbatim.
  • Nonces are single-use and short-lived (~10 min). A nonce store that only checks existence without deleting will let a replay through; one that deletes before validation will fail intermittently on retries.
  • SSO can lock you out. Once enable_sso is on, local passwords stop working. Before you enable it, create the API user and keep a local admin account or a recovery path. The API key must be created before you're locked out of the admin UI.
  • API key scope matters. Admin endpoints (create user, suspend, delete) need a global "All Users" key whose apiUser is an actual admin, or the system user. A per-user key will produce 403s you'll misread as a code bug.
  • Guzzle exceptions. The API client throws on 4xx/5xx. Catch GuzzleHttp\Exception\RequestException, branch on status code, and add retry/backoff for 429s — Discourse rate-limits aggressively.
  • createUser is not idempotent. It returns 422 for existing emails. Either look up first or expect the exception in a job.
  • Username collisions. Discourse enforces its own username rules (length, allowed characters) and will mangle or reject yours. Store the accepted username back in your database rather than regenerating it on every login.
  • HTTPS only. Discourse requires a reachable HTTPS sso_url. For local development, tunnel with ngrok or expose rather than disabling the check.
  • Pin the version. With zero dependents and infrequent releases, use an exact constraint ("cviebrock/discourse-php": "1.x") and add a smoke test that exercises getSignInString() against a known secret and nonce. It's the cheapest way to detect a break on upgrade.
  • The package won't validate your app's own session. It only proves the request came from Discourse. Authenticating the user is entirely your job — and if you use the signed email to log them in automatically, you've built an account-takeover vector. Always require an established local session.
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.
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
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