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

37Signals Laravel Package

socialiteproviders/37signals

Socialite provider for 37signals that adds OAuth authentication to Laravel apps via the Socialite Providers manager. Install it, configure your 37signals credentials, and let users sign in with their 37signals account.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Install the provider and its manager dependency

composer require socialiteproviders/37signals:^4.1 socialiteproviders/manager:^4.0

4.1.0 is the first release with PHP 8 support — if you're on PHP 7.x you can still install, but on PHP 8.0+ pin ^4.1. Laravel Socialite (laravel/socialite) must already be installed; the provider is a driver for it, not a standalone auth package.

2. Add credentials to config/services.php

37signals uses Launchpad as its identity service. Register an integration in Launchpad, then:

'37signals' => [
    'client_id'     => env('THIRTYSIGNALS_CLIENT_ID'),
    'client_secret' => env('THIRTYSIGNALS_CLIENT_SECRET'),
    'redirect'      => env('THIRTYSIGNALS_REDIRECT_URI'), // e.g. https://app.test/auth/37signals/callback
],

3. Register the provider with Socialite

If you're on socialiteproviders/manager 4.3+, the driver is resolved automatically from the services.php key — skip this step and go straight to the routes. On older manager versions, listen for the event (in EventServiceProvider::$listen, or Event::listen() inside AppServiceProvider::boot() on Laravel 11+ where there is no EventServiceProvider by default):

protected $listen = [
    \SocialiteProviders\Manager\SocialiteWasCalled::class => [
        \SocialiteProviders\37signals\Provider::class,
    ],
];

4. First use case — confirm the round trip works

Route::get('/auth/37signals', fn () => Socialite::driver('37signals')->redirect());

Route::get('/auth/37signals/callback', function () {
    $user = Socialite::driver('37signals')->user();
    dd($user->getId(), $user->getEmail(), $user->getName(), $user->getRaw());
});

The first thing to inspect is getRaw() — Launchpad's payload is not a flat standard profile (see Gotchas), so print it before you design your users mapping.

Implementation Patterns

Standard redirect / callback controller pair

class SignalsAuthController extends Controller
{
    public function redirect()
    {
        return Socialite::driver('37signals')->redirect();
    }

    public function callback()
    {
        try {
            $signals = Socialite::driver('37signals')->user();
        } catch (InvalidStateException $e) {
            return redirect()->route('login')->withErrors('Sign-in expired, please try again.');
        }

        $user = $this->accounts->findOrCreateFromSignals($signals);

        Auth::login($user, remember: true);

        return redirect()->intended('/dashboard');
    }
}

Wrap the driver in your own adapter, not in controllers

Given the package is community-maintained with a sparse release history, keep all Socialite::driver('37signals') calls inside one class (e.g. App\Auth\SignalsProvider). That class owns the driver name string, the field mapping, and the token persistence. When you eventually have to fork or replace the package, only that file changes.

Persist tokens, not just the user

Launchpad access tokens expire (roughly two weeks), and the provider hands you the whole OAuth payload through the user object:

SocialAccount::updateOrCreate(
    ['provider' => '37signals', 'provider_id' => $signals->getId()],
    [
        'user_id'       => $user->id,
        'access_token'  => $signals->token,
        'refresh_token' => $signals->refreshToken,
        'expires_at'    => now()->addSeconds($signals->expiresIn ?? 0),
    ]
);

You will need refresh_token later if you call 37signals APIs (Basecamp, Highrise, Campfire) on the user's behalf — Socialite does not refresh tokens for you.

Map Launchpad's identity and accounts explicitly

Treat getId()/getEmail()/getName() as convenience wrappers and read getRaw() for anything authoritative. Build your own value object:

$raw = $signals->getRaw();
$identity = $raw['identity'] ?? [];
$accounts = collect($raw['accounts'] ?? []); // ['product' => 'bc3', 'name' => 'Acme', 'href' => ...]

Use identity.id as your provider_id (it's stable), identity.email_address for matching, and store accounts if your app needs to know which Basecamp/Highrise product the person belongs to.

Account linking, not just auto-registration

The usual production pattern: look up an existing social_accounts row by (provider, provider_id); otherwise match on verified email and prompt for password confirmation before linking; otherwise create a new user. Never silently merge on email alone if your app has sensitive data.

Calling 37signals APIs with the stored token

$response = Http::withToken($account->access_token)
    ->withHeaders(['User-Agent' => config('app.name') . ' ([email protected])'])
    ->get('https://launchpad.37signals.com/authorization.json');

37signals rejects requests without a descriptive User-Agent, so bake it into a single SignalsClient service rather than repeating headers.

Testing

  • Feature test the callback by mocking the Socialite contract: Socialite::shouldReceive('driver->user')->andReturn($fakeUser) where $fakeUser is a small class implementing Laravel\Socialite\Contracts\User, or a Laravel\Socialite\Two\User with map() called on it.
  • Assert the redirect route returns a 302 to launchpad.37signals.com/authorization/new with the right client_id and redirect_uri query params — this catches config mistakes early.
  • For token-refresh logic, fake the HTTP layer, not Socialite.

Multi-guard / API usage

For SPA or mobile flows use Socialite::driver('37signals')->stateless()->user() and validate the state yourself; the session-based state check will otherwise throw InvalidStateException.

Gotchas and Tips

The driver name starts with a digit. '37signals' is a fine config key, but an environment variable named 37SIGNALS_CLIENT_ID is awkward or invalid in shell/dotenv contexts. Use a prefixed name such as THIRTYSIGNALS_CLIENT_ID and reference it explicitly in services.php.

"Driver [37signals] is not supported." This is the single most common failure and it is almost never an auth problem. Causes, in order of likelihood: (a) you're on socialiteproviders/manager < 4.3 and forgot the SocialiteWasCalled listener; (b) the listener is registered in an EventServiceProvider that Laravel 11+ isn't loading — use Event::listen() in AppServiceProvider::boot(); (c) a stale bootstrap/cache/config.php from php artisan config:cache that predates your services.php edit. Run php artisan config:clear after touching credentials.

The user payload is not a standard Socialite profile. Launchpad returns identity plus an accounts array, not a flat name/email/avatar. Consequences: getEmail() can be null (unverified or hidden addresses), getNickname()/getAvatar() are typically empty, and getName() is composed from identity.first_name/last_name. Always null-check the email before using it as a unique key, and never assume an avatar URL exists.

Redirect URI mismatch is fatal and generic. Launchpad requires a byte-exact redirect URI match with what's registered for the integration — trailing slash, http vs https, and localhost vs 127.0.0.1 all matter. Errors surface as an OAuth error page from 37signals, not a Laravel exception, so log the full callback query string while debugging.

No automatic token refresh. expires_in is short-lived relative to typical session lengths. If you use the token for API calls, wrap them in a client that refreshes on 401 (or proactively on expires_at) and re-saves the new token. If you only use Socialite for login and throw the token away, you're fine — but be deliberate about which it is.

Scopes. The provider sends no meaningful default scope. If you call ->setScopes([...]) with values Launchpad doesn't recognise you'll get invalid_scope back on the authorization redirect; 37signals controls product access through the user's own account membership, not OAuth scopes.

Debugging aids. Log $user->getRaw() on the first successful login from staging. Swap Socialite's HTTP client via Socialite::driver('37signals')->setHttpClient($client) when you need request-level tracing, or point Guzzle at a logging middleware. InvalidStateException means session/state problems (expired session, cookie domain, or mixing stateless() with the session flow) — not credentials.

Package maturity is the real risk. Last release was 2020-12-01 and the repo is a subtree split of the monorepo, so issues and PRs live at SocialiteProviders/Providers. Pin ^4.1, and if you hit a bug, expect to fork the single Provider class into app/ and register your own driver via Socialite::extend('37signals', ...) rather than waiting on upstream. Keep the adapter boundary from the Implementation Patterns section so that swap is a one-file change.

Extension points worth knowing. The provider extends SocialiteProviders\Manager\AbstractProvider, so overriding endpoints or the user-mapping logic is a matter of subclassing and registering your subclass under the same driver name with Socialite::extend(). That's often cheaper than patching vendor code with a Composer patch plugin.

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.
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log
gregsphotography/site-builder-components
io-digital/clickatell
snowsoft/laravel-admin
oxygen/auth
relicsoftware/barakah-payment-gateway
tobya/work-with-composer
equipc/laravel-email-validator
equipc/laravel-email-database-log
adedsec/nova-translated-labels
wazza/laravel-db-encryption
hokny/laravel-generator
vonsogt/backup
jengo/queues
jeremykenedy/laravel-observability
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums