Intro
Today, we'll look at the full flow for implementing OAuth in your Laravel application.
⚠️ This blog shows session-based OAuth implementation, not token-based.
Table of Contents
- Build Order
- Folder Structure
- Prerequisite
- Implementation
- Migration
- Package Installation
- Configuration
- Why Session-Based Authentication?
- Customize Provider
- Registering Provider
- Controller
- Routing
- 401 Return
🔨 Build Order
1. Migration
2. Package Installation
3. Configuration
4. Custom Provider
5. Registering Provider
6. Controller
7. Routes
8. JSON Return🗂️ Folder Structure
aim_backend/
├── app/
│ ├── Http/
│ │ └── Controllers/
│ │ └── Api/
│ │ └── AuthController.php
│ ├── Models/
│ │ └── PlanningCenterUser.php
│ ├── Providers/
│ │ └── AppServiceProvider.php
│ └── Socialite/
│ └── PlanningCenterProvider.php
├── bootstrap/
│ └── app.php
├── config/
│ ├── app.php
│ ├── auth.php
│ ├── sanctum.php
│ └── services.php
├── database/
│ └── migrations/
│ ├── ..._create_planning_center_users_table.php
│ └── ..._create_personal_access_tokens_table.php
├── routes/
│ ├── api.php
│ └── web.php
└── ...
aim-client/
├── lib/
│ ├── axios.js
│ └── token.js
└── ...🛹 Prerequisite
- Understand the structure of the response Planning Center (or your OAuth provider) returns.
- For Planning Center, you can check out this link: https://api.planningcenteronline.com/docs/overview/json-api
- Decide which fields you're going to store.
- In this post, I'm going to use
name,email, andavatar_url.
- In this post, I'm going to use
{
"data": [
{
"type": "Person",
"id": "13",
"attributes": {
/* ... */
},
"relationships": {
"emails": {
"data": [
{ "type": "Email", "id": "11914559" },
{ "type": "Email", "id": "53967483" }
]
},
"phone_numbers": {
"data": [
{ "type": "PhoneNumber", "id": "6706677" }
]
}
}
}
],
"included": [
{
"type": "Email",
"id": "11914559",
"attributes": {
"address": "matt@example.com"
/* ... */
}
},
{
"type": "Email",
"id": "53967483",
"attributes": {
"address": "matthew@example.com"
/* ... */
}
},
{
"type": "PhoneNumber",
"id": "6706677",
"attributes": {
"number": "(123) 456-7890"
/* ... */
}
}
]
}
Step1. Migration
I'm going to write a migration for the users.
In this case, I'm using Planning Center as the OAuth provider, so the table name becomes planning_center_users_table.
Artisan Command
php artisan make:migration create_planning_center_users_table
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('planning_center_users', function (Blueprint $table) {
$table->id();
$table->string('planning_center_id')->unique()->comment('Planning Center OAuth 고유 ID');
$table->string('name')->nullable();
$table->string('email')->nullable();
$table->string('avatar_url')->nullable();
$table->string('role')->default('user')->comment('user | admin');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('planning_center_users');
}
};The result

Step2. Package Installation
Laravel Socialite is an official Laravel package that provides a simple, expressive way to authenticate users through OAuth providers like Google, Facebook, GitHub, and Twitter. It handles the OAuth flow and lets you retrieve the authenticated user's profile information with just a few lines of code.
composer require laravel/socialite socialiteproviders/planningcenter
Step3. Configuration
You may want to configure each service to its own environment so nothing directly references the env file.
If you want to know more about how Laravel layers work, please check out my blog post.
>> Laravel layers
config/services.php

Code Link: https://github.com/AIM-Church-Busan/aim_backend/blob/develop/config/services.php
❗Important thing to notice
In this project, OAuth login (for regular users) is kept completely separate from Admin login (Sanctum, for the admin panel). Because of that, the auth setup below might look a bit confusing at first glance.
☁️ My initial thought
The reason this ended up as session-based login is that I initially assumed a single browser couldn't carry two independent login states at once.
Since the admin panel uses Sanctum and regular users authenticate via OAuth, both in the same app.
That assumption is what led me to build a token-based flow first instead of a session-based one. You'll still find remnants of that token-based code in the actual codebase (some of it commented out).
🪙 Why Token to Session?
My assumption was WRONG.
Sanctum supports multiple guards side by side, as long as each is explicitly checked rather than relying on a default order.
- Turn on
statefulApi()middleware so Sanctum trusts cookies from the frontend - Log the user in via
Auth::guard('planning_center')->login($user)instead of returning a token - Always specify the guard explicitly (
auth('planning_center'),auth:planning_centermiddleware) — never rely on Sanctum's default order - Frontend: add
withCredentials: true, and fetch/sanctum/csrf-cookieonce before login
⚡ Troubleshooting
In addition, I want to share some troubleshooting I went through implementing token-based authentication.
1. Token got lost on redirect
OAuth callbacks happen via full-page browser redirect, not an API call. No JavaScript ever ran to catch the JSON response containing the token.
It was issued and immediately discarded. Every request after that had no Authorization header, so /api/auth/me kept returning 401.
2. Wrong guard, wrong user
Sanctum was configured with 'guard' => ['web', 'planning_center'].
It checks guards in that order and uses whichever resolves first.
Any code calling auth()->user() without specifying a guard would silently return the admin account instead of the OAuth user, if an admin session happened to be active in the same browser.
3. Cookies weren't enabled yet
bootstrap/app.php had no stateful middleware configured, so Sanctum had no way to trust cookies from the frontend in the first place.
Finally, here are the configuration codes.
config/auth.php


Code Link: https://github.com/AIM-Church-Busan/aim_backend/blob/develop/config/auth.php
Step4. Write a Custom Socialite Provider
Why I customized it:
I couldn't use socialiteproviders/planningcenter's default implementation as-is - I had an issue with how it handled token-exchange request parameters. So I *subclass it and override just getAccessTokenResponse().
✏️ If you want to know more about how Planning Center OAuth originally inherits classes, you can check out the post below. - This post is a great way to understand how subclassing works.
>> Understanding subclass with OAuth Blog Link
app/Socialite/PlanningCenterProvider.php
<?php
namespace App\Socialite;
use GuzzleHttp\RequestOptions;
use SocialiteProviders\PlanningCenter\Provider as BaseProvider;
class PlanningCenterProvider extends BaseProvider
{
public function getAccessTokenResponse($code)
{
if (is_null($code)) {
$code = request()->query('code');
}
try {
$response = $this->getHttpClient()->post($this->getTokenUrl(), [
RequestOptions::FORM_PARAMS => [
'grant_type' => 'authorization_code',
'code' => $code,
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'redirect_uri' => $this->redirectUrl,
],
]);
return json_decode((string) $response->getBody(), true);
} catch (\GuzzleHttp\Exception\ClientException $e) {
throw $e;
}
}
}
Step5. Register the Provider
Wire it up at boot time, so Socialite recognizes the 'planning-center' driver name.
app/Providers/AppServiceProvider.php

Code Link: https://github.com/AIM-Church-Busan/aim_backend/blob/develop/app/Providers/AppServiceProvider.php
Step 6. Write the Controller
The heart of it is callback(). Note that it still calls *stateless()
✏️ Explaining about stateless here would take too long, so I've separated the subject into another post; worth checking it out!
>> Blog Post Link about Stateless on Laravel and Spring Boot
app/Http/Controllers/Api/AuthController.php

redirect()/callback()still usestateless()because the round trip out to Planning Center and back handles its ownstateverification via a self-signed value, not our session.- Logging the user into our session is a separate step, handled by
Auth::guard(...)->login().
The image above has been cropped for readability.
Please check out the full code below.
Step7. Routing
This is the part most commonly missed. redirect/callback need to live in routes/web.php, not routes/api.php.
routes/web.php

For session cookies to work, you need middleware like StartSession and EncryptCookies, and those are only included by default in Laravel's web middleware group.
No matter how many times you call Auth::guard('planning_center')->login(), nothing gets persisted if the route has no session middleware in front of it.
Code Link: https://github.com/AIM-Church-Busan/aim_backend/blob/develop/routes/web.php
Authenticated APIs that run after login (logout, me, event likes/registrations, etc.) stay in routes/api.php as normal, protected by the auth:planning_center middleware.
routes/api.php

The browser attaches the session cookie automatically on API requests too (given the shared domain setup), so checking authentication is fine to do from api.php. It's only the moment of creating the session that needs the web group's middleware.
Code Link: https://github.com/AIM-Church-Busan/aim_backend/blob/develop/routes/api.php
Step 8. Return JSON 401s for API Requests
In bootstrap/app.php, override exception rendering so that, instead of Laravel's default redirect-to-login-page behavior, unauthenticated API requests get back a JSON 401 that's easy for an API client to handle.
bootstrap/app.php

Code Link: https://github.com/AIM-Church-Busan/aim_backend/blob/develop/bootstrap/app.php
🪸 Conclusion
Getting Planning Center OAuth and Sanctum to work together was a genuinely tricky problem.
I'm still not entirely convinced this is the best way to run an admin panel and a public-facing app on the same backend, but that's the trade-off you make when you reach for Laravel in the first place.
I'd like to dig into how other teams handle this kind of setup, and I'll come back with a case study once I have.
