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.
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.
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
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.302 to launchpad.37signals.com/authorization/new with the right client_id and redirect_uri query params — this catches config mistakes early.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.
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.
How can I help you explore Laravel packages today?