RiaChoi Text Logo.
Wiring Up Planning Center OAuth in Laravel: the Session Based Way

Laravel

Sanctum

OAuth

php

Wiring Up Planning Center OAuth in Laravel: the Session Based Way

A walkthrough of implementing Planning Center OAuth in Laravel

Ria ChoiAugust 9th, 2026

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

  1. Build Order
  2. Folder Structure
  3. Prerequisite
  4. Implementation
    1. Migration
    2. Package Installation
    3. Configuration
      • Why Session-Based Authentication?
    4. Customize Provider
    5. Registering Provider
    6. Controller
    7. Routing
    8. 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.
  • Decide which fields you're going to store.
    • In this post, I'm going to use name, email, and avatar_url.
{
  "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');
    }
};

Code Link : https://github.com/AIM-Church-Busan/aim_backend/blob/develop/database/migrations/2026_06_27_052238_create_planning_center_users_table.php

The result

planning_center_users_table
The table

 

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

configuration
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_center middleware) — never rely on Sanctum's default order
  • Frontend: add withCredentials: true, and fetch /sanctum/csrf-cookie once 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

Blog image
auth.php
Blog image
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;
        }
    }
}

Code Link: https://github.com/AIM-Church-Busan/aim_backend/blob/develop/app/Socialite/PlanningCenterProvider.php

 

Step5. Register the Provider

Wire it up at boot time, so Socialite recognizes the 'planning-center' driver name.

app/Providers/AppServiceProvider.php

Blog image
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

Blog image
AuthController.php
  • redirect()/callback() still use stateless() because the round trip out to Planning Center and back handles its own state verification 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.

Code Link: https://github.com/AIM-Church-Busan/aim_backend/blob/develop/app/Http/Controllers/Api/AuthController.php

 

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

Blog image
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

Blog image
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

Blog image
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.

 

Share

Related contents