madbox-99 / laravel-user-team-sync
User and team synchronization between a subscriber hub and secondary Laravel apps
Package info
github.com/MadBox-99/laravel-user-team-sync
pkg:composer/madbox-99/laravel-user-team-sync
Requires
- php: ^8.3
- laravel/framework: ^11.0 || ^12.0 || ^13.0
Requires (Dev)
- laravel/pint: ^1.0
- orchestra/testbench: ^9.0 || ^10.0 || ^11.0
- pestphp/pest: ^3.0 || ^4.0
README
Laravel package for synchronizing users and teams across multiple Laravel applications using a Publisher/Receiver pattern.
Requirements
- PHP 8.3+
- Laravel 11 or 12
Installation
composer require madbox-99/laravel-user-team-sync
Then run the install command:
php artisan user-team-sync:install
This will:
- Ask for your sync mode (publisher/receiver/both)
- Generate or accept an API key
- Update your
.envfile - Publish the config and migration files
- Optionally run migrations
Manual Installation
If you prefer to install manually:
php artisan vendor:publish --tag=user-team-sync-config php artisan vendor:publish --tag=user-team-sync-migrations php artisan migrate
Then add these to your .env:
USER_TEAM_SYNC_MODE=receiver USER_TEAM_SYNC_API_KEY=your-secret-key
Configuration
Modes
| Mode | Description |
|---|---|
publisher |
Sends sync events to other apps |
receiver |
Receives sync events from a publisher |
both |
Sends and receives |
client |
Delegates authentication to a central identity provider instead of receiving pushed user/team data |
Publisher Setup
Set mode to publisher. Receiver apps can be stored in the config file or in the database.
Option A: Config-based apps (default)
Define apps in config/user-team-sync.php:
'publisher' => [ 'app_source' => 'config', // default 'api_key' => env('USER_TEAM_SYNC_API_KEY'), 'apps' => [ 'crm' => [ 'url' => env('CRM_APP_URL'), 'api_key' => env('CRM_APP_API_KEY'), // optional, falls back to default 'active' => true, ], 'shop' => [ 'url' => env('SHOP_APP_URL'), 'api_key' => env('SHOP_APP_API_KEY'), 'active' => true, ], ], ],
Option B: Database-based apps (recommended)
Store apps in the sync_apps table for dynamic management:
USER_TEAM_SYNC_APP_SOURCE=database
The install command (php artisan user-team-sync:install) can set this up interactively, or you can manage apps manually:
use Madbox99\UserTeamSync\Models\SyncApp; // Add a new app SyncApp::create([ 'name' => 'crm', 'url' => 'https://crm.example.com', 'api_key' => 'secret-key', // stored encrypted 'is_active' => true, ]); // Deactivate an app SyncApp::where('name', 'crm')->update(['is_active' => false]);
API keys are stored using Laravel's
encryptedcast. Make sure yourAPP_KEYis set.
Receiver Setup
Set mode to receiver and configure the API key:
'receiver' => [ 'api_key' => env('USER_TEAM_SYNC_API_KEY'), 'route_prefix' => 'api', 'role_driver' => 'spatie', 'default_role' => 'subscriber', 'default_active' => false, ],
The receiver API key must match the publisher's key for that app.
Client mode (SSO)
Set mode to client to stop receiving pushed user/team data and instead authenticate
users against a central Laravel Passport identity provider, rebuilding this app's
local state from the token's claims on every login and on every revalidation.
USER_TEAM_SYNC_MODE=client IDENTITY_APP_KEY=crm IDENTITY_URL=https://your-identity-provider.example IDENTITY_CLIENT_ID=your-oauth-client-id IDENTITY_CLIENT_SECRET=your-oauth-client-secret IDENTITY_REDIRECT_URI=https://this-app.example/auth/callback IDENTITY_REVALIDATE_MINUTES=15 IDENTITY_GRACE_HOURS=24 IDENTITY_RETRY_MINUTES=5 # Where a failed or refused callback sends the user to retry signing in. IDENTITY_LOGIN_URL=/login
'client' => [ // This app's own key. Must equal sync_apps.name on the publisher and the // slug of the plan category that grants access to this app. A login is // refused when this key is absent from the token's `apps` claim. 'app_key' => env('IDENTITY_APP_KEY'), 'identity_url' => env('IDENTITY_URL'), 'client_id' => env('IDENTITY_CLIENT_ID'), 'client_secret' => env('IDENTITY_CLIENT_SECRET'), 'redirect_uri' => env('IDENTITY_REDIRECT_URI'), 'scopes' => '', 'http_timeout' => env('IDENTITY_HTTP_TIMEOUT', 10), // Kept short: the revalidation middleware runs on every authenticated // page, so a hanging connect would otherwise pin a worker and the // session lock for the full read timeout, request after request. 'http_connect_timeout' => env('IDENTITY_HTTP_CONNECT_TIMEOUT', 3), // Re-fetch the claims once the session's last check is older than this. 'revalidate_after_minutes' => env('IDENTITY_REVALIDATE_MINUTES', 15), // How long a session survives while the identity provider is unreachable. 'grace_hours' => env('IDENTITY_GRACE_HOURS', 24), // How long to wait before retrying an unreachable provider. Without it // every request would retry, so a slow provider would cost every page // load the full HTTP timeout for the whole grace window. 'retry_after_minutes' => env('IDENTITY_RETRY_MINUTES', 5), // Comma-separated e-mails. Non-empty during a phased rollout: only these // users go through SSO, everyone else keeps using the legacy login and // the legacy push. Empty means everyone goes through SSO. 'allowlist' => array_values(array_filter(array_map('trim', explode(',', (string) env('IDENTITY_SSO_ALLOWLIST', ''))))), // Keeps the legacy receiver endpoints mounted while both worlds run // side by side. Turn off once the rollout is complete. 'legacy_receiver' => env('IDENTITY_LEGACY_RECEIVER', true), // Maps a token role name onto a local role name; leave empty to rely on // the case-insensitive fallback in IdentityProvisioner. 'role_map' => [], // Where to send a user who authenticated but has no subscription // covering this app. 'subscribe_url' => env('IDENTITY_SUBSCRIBE_URL'), // Where to send a user back to retry signing in after a failed or // refused callback. Defaults to this app's own login page, since a // phased rollout keeps the legacy password form available as a // fallback for exactly this case. 'login_url' => env('IDENTITY_LOGIN_URL', '/login'), ],
Routes
| Method | Route | Name | Purpose |
|---|---|---|---|
| GET | /auth/redirect |
identity.redirect |
Starts the OAuth authorization-code + PKCE handshake with the identity provider |
| GET | /auth/callback |
identity.callback |
Exchanges the code for tokens, fetches claims, provisions the local user, and signs them in |
Point your login link at route('identity.redirect') (it accepts an optional ?intended= relative
path to return to after login). Both routes are registered automatically under the web middleware
group when mode is client — nothing else to add to routes/web.php.
Callback failure pages
/auth/callback renders a package view instead of a raw framework error for every failure it can
distinguish from a forged or stale request. Each page uses only __() strings — translate them by
publishing a JSON translation file — and none of them render a token, a client secret, a claims
payload, or an exception message.
| Situation | Status | View | Notes |
|---|---|---|---|
The identity provider is unreachable, erroring, or answers with a malformed payload (IdentityUnavailableException) |
503 |
identity.unavailable |
Transient. The message invites the user to try again shortly; links to client.login_url. |
The provider answers and refuses the code or token (IdentityRejectedException) |
401 |
identity.rejected |
An authentication failure, not an outage. Invites a retry; links to client.login_url. Sends a WWW-Authenticate: Bearer realm="<identity_url>" header, as RFC 7235 requires on every 401 — harmless for this interactive HTML page, but conformant. |
The claims cannot be reconciled with local data, e.g. an e-mail already held by a different local identity (IdentityConflictException) |
409 |
identity.conflict |
The user cannot fix this themselves — the page asks them to contact support and deliberately never prints the colliding e-mail address or any other claim data. |
The callback's state does not match this session's handshake, or the user authenticated but is not on client.allowlist |
403 |
identity.refused |
Both causes render the exact same response — status, body, and headers — on purpose: at the response level nothing distinguishes them, so an attacker cannot probe which e-mail addresses are allowlisted by reading what a callback sends back. Links to client.login_url. |
| The user authenticated but has no subscription covering this app | 200 |
identity.not-entitled |
Unchanged from before — see client.subscribe_url. |
In every refusal path the PKCE handshake (identity.state, identity.code_verifier, identity.intended)
is cleared before the response is built, exactly as it already was for a mismatched state — a friendlier
page must never leave a reusable handshake behind.
The 403 response is identical, but the two paths that reach it are not equal-time. A mismatched
state is rejected immediately. A not-allowlisted user is rejected only after the callback has already
made two real outbound calls to the identity provider (exchangeCode then fetchClaims), so the second
path is measurably slower. This is a known, accepted gap, not an oversight: exploiting it requires an
attacker's own valid state/code pair — i.e. their own handshake, initiated from their own login
attempt — which does not obviously extend into a way to learn other people's e-mail addresses against
the allowlist. It is not closed, and it should be revisited if that assumption ever stops holding (for
example if authorization codes became replayable, or another actor could otherwise reach the
not-allowlisted branch without completing a real handshake of their own).
Keeping sessions self-healing: RevalidateIdentity
Signing in only provisions the user once. To pick up a team rename, a new membership, a role
change or a cancelled subscription without any push from the publisher, add
Madbox99\UserTeamSync\Client\Http\Middleware\RevalidateIdentity to your app's authenticated
middleware stack — for a Filament panel, its authMiddleware():
->authMiddleware([ Authenticate::class, \Madbox99\UserTeamSync\Client\Http\Middleware\RevalidateIdentity::class, ])
On every request past a fresh CHECKED_AT (older than revalidate_after_minutes), the middleware:
- Re-fetches the claims from
/api/userinfo. A401first retries once with the refresh token — an aged-out access token is not the same thing as revoked access — and only logs the user out if the refresh also fails. - Re-runs
IdentityProvisioner, so a renamed team, an added/removed membership or a role change lands locally within onerevalidate_after_minuteswindow. - Logs the user out if this app's
app_keyhas disappeared from the token'sappsclaim — this is how a cancelled subscription takes effect fleet-wide with no push. - Treats a
5xx/unreachable provider — or a 2xx whose body is not a well-formed claims payload, such as a maintenance page served mid-deploy — as an outage, not as revoked access: the session survives for up tograce_hoursand only logs out once the grace window is exhausted. Collapsing this distinction would turn a five-minute identity-provider outage into a forced logout across every app in the fleet. - Retries a failing provider at most once per
retry_after_minutesrather than on every request, so a slow provider does not add its timeout to every page load for every user. The grace window still runs from the first failure, so a long outage expires on schedule. - Never lets an unexpected error escape: anything other than a deliberate rejection or an unreconcilable conflict is logged and tolerated rather than 500-ing the page.
The middleware only touches sessions that were established through SSO. During a phased rollout (see
allowlist) the same app still signs other users in through the ordinary password form; those sessions carry no identity token and are passed through untouched.
SSO logins deliberately mint no "remember me" cookie. A recaller cookie outlives the session and would re-authenticate the user into a fresh session holding no identity state, which the middleware passes through by design — permanently exempting anyone who idles past
SESSION_LIFETIMEfrom revalidation. After the session lapses the user is sent back through the identity provider instead, which for an already-signed-in user is a transparent round trip.
Operational note: malformed claims count as an outage
A 2xx response whose body is not a well-formed claims payload is treated exactly like an
unreachable provider, not like a bad request. That covers a maintenance or proxy error page
served with HTTP 200, but also a genuine data-quality fault on the provider side: an orgs entry
with an empty slug or name, or a user with an empty name.
What that looks like in practice: affected sessions keep working for grace_hours and then log out
silently, instead of the page erroring loudly. The trade is deliberate — a fleet-wide 500 on every
page of every module app is the worse failure — but it means a provider-side data fault is
quiet, so watch the logs rather than waiting for user reports:
user-team-sync: grace period expired while the identity provider was unreachable— sessions are now being logged out; thereasonfield carries the provider-side symptom.user-team-sync: unexpected failure during identity revalidation— a bug rather than an outage; logged with the exception class and throw site only. Claims, tokens and exception messages are never logged, because aQueryExceptioninterpolates its bindings and would leak personal data.
Usage
Automatic Sync (Observer)
When auto_observe is enabled (default), the package automatically watches your User model for changes to configured fields (email, role by default) and syncs them to all active receiver apps.
// This automatically triggers sync to all receiver apps $user->update(['role' => 'admin']);
Manual Sync via Facade
use Madbox99\UserTeamSync\Facades\UserTeamSync; // Create a user on all receiver apps UserTeamSync::createUser( email: 'john@example.com', name: 'John Doe', password: 'plain-text-password', // hashed automatically before sending role: 'editor', ownerEmail: 'owner@example.com', ); // Sync user changes UserTeamSync::syncUser('john@example.com', [ 'new_email' => 'john.doe@example.com', 'role' => 'admin', ]); // Create a team on all receiver apps UserTeamSync::createTeam( teamName: 'Marketing', userEmail: 'john@example.com', slug: 'marketing', // optional, auto-generated from name ); // Toggle user active status on a specific app UserTeamSync::toggleUserActive( userEmail: 'john@example.com', isActive: true, appKey: 'crm', );
API Endpoints (Receiver)
All endpoints are protected by Bearer token authentication.
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/create-user |
Create a new user |
| POST | /api/sync-user |
Update user fields |
| POST | /api/toggle-user-active |
Set active/inactive status |
| POST | /api/create-team |
Create a team |
| POST | /api/update-team |
Apply a team rename (see below) |
| GET | /api/user-teams |
Get user's teams |
| POST | /api/sync-password |
Sync password hash |
| GET | /api/identity-audit |
Diff users/teams/memberships against the publisher |
| POST | /api/identity-uuids |
Bulk-apply the publisher's uuid mapping |
Team renames
Receivers used to match teams by slug forever after creation, so renaming a team
on the publisher silently and permanently broke the cross-app link. TeamSyncObserver
now propagates changes to the fields in publisher.team_sync_fields (default
name, slug) via UpdateTeamJob.
/api/update-team identifies the team by uuid. It falls back to original_slug
— the team's pre-rename slug, which is what the receiver still knows it by —
only when the local team has no uuid of its own, and then adopts the publisher's
uuid so the next rename skips the fallback. A local uuid that differs from the
incoming one means the two sides disagree about which team this is; that returns
409 rather than renaming the wrong team.
A 404 is normal, not an error: the publisher fans out to every active app
regardless of entitlement, so most apps do not know most teams. Those are logged
with status skipped.
Events
Listen to these events for custom logic:
| Event | When |
|---|---|
UserCreatedFromSync |
User created on receiver |
UserSynced |
User fields updated |
PasswordSynced |
Password synced to receiver |
UserActiveToggled |
Active status changed |
TeamCreatedFromSync |
Team created on receiver |
TeamUpdatedFromSync |
Team renamed on receiver |
TeamSynced |
A receiver accepted a team change |
TeamSyncFailed |
A receiver rejected a team change (a 404 does not count) |
SyncFailed |
Any user sync operation failed |
// In EventServiceProvider or listener use Madbox99\UserTeamSync\Events\UserCreatedFromSync; class HandleSyncedUser { public function handle(UserCreatedFromSync $event): void { // $event->user } }
Logging
All sync operations are logged to the sync_logs table. Configure in config/user-team-sync.php:
'logging' => [ 'enabled' => true, 'table' => 'sync_logs', 'retention_days' => 30, ],
Environment Variables
| Variable | Description | Default |
|---|---|---|
USER_TEAM_SYNC_MODE |
Sync mode | receiver |
USER_TEAM_SYNC_API_KEY |
API key for authentication | — |
USER_TEAM_SYNC_APP_SOURCE |
App storage: config or database |
config |
USER_TEAM_SYNC_USER_MODEL |
User model class | App\Models\User |
USER_TEAM_SYNC_TEAM_MODEL |
Team model class | App\Models\Team |
USER_TEAM_SYNC_QUEUE |
Queue name for jobs | default |
USER_TEAM_SYNC_QUEUE_CONNECTION |
Queue connection | null |
USER_TEAM_SYNC_TRIES |
Job retry attempts | 3 |
USER_TEAM_SYNC_BACKOFF |
Seconds between retries | 60 |
USER_TEAM_SYNC_TIMEOUT |
HTTP timeout in seconds | 10 |
IDENTITY_APP_KEY |
This app's key in the apps claim (client mode) |
— |
IDENTITY_URL |
Identity provider base URL (client mode) | https://cegem360.eu |
IDENTITY_CLIENT_ID |
OAuth client ID (client mode) | — |
IDENTITY_CLIENT_SECRET |
OAuth client secret (client mode) | — |
IDENTITY_REDIRECT_URI |
OAuth redirect URI, must match /auth/callback (client mode) |
— |
IDENTITY_HTTP_TIMEOUT |
HTTP timeout in seconds for identity provider calls | 10 |
IDENTITY_REVALIDATE_MINUTES |
Minutes before RevalidateIdentity re-checks the session |
15 |
IDENTITY_GRACE_HOURS |
Hours a session survives while the identity provider is unreachable | 24 |
IDENTITY_SSO_ALLOWLIST |
Comma-separated e-mails allowed through SSO during a phased rollout | — (everyone) |
IDENTITY_LEGACY_RECEIVER |
Keep legacy receiver endpoints mounted alongside client mode | true |
IDENTITY_SUBSCRIBE_URL |
Where to send an authenticated user with no entitlement for this app | https://cegem360.eu |
IDENTITY_LOGIN_URL |
Where to send the user back to retry after a failed or refused callback | /login |
Testing
vendor/bin/pest
License
MIT License. See LICENSE for details.