socialiteproviders/todoist is an OAuth 2 driver for Laravel Socialite. It does one job: it lets you complete the Todoist authorization code flow and hands you back the user identity plus an access token you then use against Todoist's own API. It is not a Todoist client — plan on pairing it with Http::withToken(...) or a Todoist SDK.
1. Install
composer require socialiteproviders/todoist
This pulls in socialiteproviders/manager (auto-discovered) and the Socialite version the release targets. Note that 4.3.0 shipped with only a changelog link, so treat it as a routine minor: read the 4.2.0...4.3.0 compare on GitHub before upgrading production, and pin ^4.3 once you've confirmed nothing in the diff touches the provider class.
2. Create the app in Todoist
Go to the Todoist App Management Console, create an app, and copy the Client ID and Client Secret. The Redirect URI must match exactly — scheme, host, path, and trailing slash all count.
3. Config
// config/services.php
'todoist' => [
'client_id' => env('TODOIST_CLIENT_ID'),
'client_secret' => env('TODOIST_CLIENT_SECRET'),
'redirect' => env('TODOIST_REDIRECT_URI'),
],
4. Register the listener
This is the step everyone forgets. The driver only exists once the manager event fires.
// Laravel 10 and below: app/Providers/EventServiceProvider.php
protected $listen = [
\SocialiteProviders\Manager\SocialiteWasCalled::class => [
\SocialiteProviders\Todoist\TodoistExtendSocialite::class.'@handle',
],
];
// Laravel 11/12: app/Providers/AppServiceProvider.php boot()
use Illuminate\Support\Facades\Event;
Event::listen(
\SocialiteProviders\Manager\SocialiteWasCalled::class,
\SocialiteProviders\Todoist\TodoistExtendSocialite::class.'@handle'
);
5. First use case — connect a Todoist account
The realistic first feature is "connect my Todoist account" for an already-authenticated user, so your app can read or write their tasks later. That flow is a two-route pair:
Route::get('/connect/todoist', fn () => Socialite::driver('todoist')
->scopes(['data:read_write'])
->redirect());
Route::get('/connect/todoist/callback', function () {
$todoist = Socialite::driver('todoist')->user();
auth()->user()->forceFill([
'todoist_user_id' => $todoist->getId(),
'todoist_token' => $todoist->token, // add an encrypted cast
'todoist_scopes' => request('scope'),
])->save();
return redirect()->route('dashboard');
});
Where to look first: dump Socialite::driver('todoist')->redirect()->getTargetUrl() and confirm the client_id, redirect_uri, scope, and state params look right before you write any persistence code. Almost every initial failure is visible in that URL.
Token storage is the real design decision. Todoist access tokens are long-lived and there is no refresh token. Do not model them like typical OAuth2 tokens with expiry/refresh columns you'll never populate. Store access_token, the granted scope string, and todoist_user_id; add a revoked_at or simply delete the row when a call returns 401. Use an encrypted cast on the token column — it's a bearer credential with full account access at data:read_write.
Multi-account / per-user linking. Since Todoist has no password login of its own, most apps use this as an integration link, not a login provider. Keep a todoist_accounts table keyed by (user_id, todoist_user_id) so a single user can connect more than one Todoist account, and so re-running the OAuth flow updates rather than duplicates.
Calling Todoist afterwards. The provider stops at the user object. Everything else is yours:
$response = Http::withToken($user->todoist_token)
->acceptJson()
->get('https://api.todoist.com/api/v1/tasks');
if ($response->status() === 401) {
// token revoked or scope missing — force re-consent
}
Wrap this in a small TodoistClient service resolved from the container so tests can swap it out and so retry/backoff lives in one place.
Scope selection per feature. Ask for the narrowest scope that supports the feature: data:read for a read-only dashboard, data:read_write for task creation, data:delete / project:delete only where genuinely needed. Store what was actually granted (the callback receives scope) rather than what you asked for, and gate UI on the stored value.
Stateless flows. For a mobile app or SPA doing a PKCE-less redirect through a backend, use ->stateless() and carry your own signed state in the callback route. For server-rendered web apps, keep Socialite's session-based state check — it's the CSRF protection for this flow.
Re-authorization instead of refresh. Since tokens never expire, the only "refresh" is re-running the OAuth flow. Build a reconnect route that is just the /connect/todoist route again, and link to it from any 401 handling.
Testing. There's no built-in fake for this driver. Two workable approaches:
// A: mock the driver at the Socialite facade
Socialite::shouldReceive('driver->user')->andReturn(
(new \Laravel\Socialite\Two\User)->map([
'id' => '123', 'name' => 'Ada', 'email' => '[email protected]',
'accessToken' => 'tok_abc',
])
);
// B: Http::fake() the token endpoint + user endpoint and exercise the real Provider
Http::fake([
'todoist.com/oauth/access_token' => Http::response(['access_token' => 'tok_abc', 'token_type' => 'Bearer']),
'*' => Http::response(['id' => '123', 'full_name' => 'Ada', 'email' => '[email protected]']),
]);
Approach B is worth the extra effort: it catches provider-level mapping changes when you bump from 4.2 to 4.3.
Driver [todoist] is not supported means the SocialiteWasCalled listener isn't registered — not that the package is broken. On Laravel 11+ this is the most common failure because EventServiceProvider is gone; register it in AppServiceProvider::boot().todoist and must match the string you pass to Socialite::driver(). A typo like 'todoist_app' produces a null-config error far from the cause.php artisan config:cache after changing services.php, or you'll debug a stale redirect URI for twenty minutes. Same for env changes in a container.->scopes(['data:read_write', 'data:delete']) appears to be ignored or the consent screen shows the wrong permissions, either pass a single scope or force the raw param: ->with(['scope' => 'data:read_write,data:delete']). Verify by inspecting the redirect URL — this is the single most likely silent misconfiguration with this driver.refreshToken, no expiresIn. Don't write logic that branches on them being present; on this provider they come back empty and any if ($user->refreshToken) guard will just be dead code. Handle expiry as "401 → re-consent."api/v1 endpoints. If you copied snippet code from a tutorial written before the migration, your first call after a successful OAuth handshake will 4xx — that's your integration code, not this package.full_name or a workspace context), subclass SocialiteProviders\Todoist\Provider, override mapUserToObject(), and register your own SocialiteWasCalled listener pointing at the subclass. Keeps you off the fork treadmill.composer update can move you between behavior without a readable changelog. Use ^4.3 and review the GitHub compare on each bump; run your Http::fake-based driver test as the tripwire.How can I help you explore Laravel packages today?