madbox-99/laravel-user-team-sync
Laravel package for syncing users and teams. It offers straightforward helpers for managing team membership and user assignments, keeping relationships consistent across your Eloquent models and existing Laravel application.
Install and publish.
composer require madbox-99/laravel-user-team-sync
php artisan user-team-sync:install # publishes config + migrations
php artisan migrate
Pin the constraint ("madbox-99/laravel-user-team-sync": "^1.7") rather than floating it — the upstream changelog is effectively empty, so the lockfile is your only record of what you actually reviewed.
Decide your role before touching config. Open config/user-team-sync.php and set user-team-sync.mode:
| Mode | Use when |
|---|---|
publisher |
This app is the hub / source of truth. It observes User changes and pushes them outward. |
receiver |
This app is downstream. It exposes /api/* endpoints and accepts writes. |
both |
You genuinely need bidirectional-ish behavior — rare, and the one mode most likely to surprise you. |
Where to look first, in order:
config/user-team-sync.php — the whole contract lives here: mode, publisher.sync_fields, app_source, queue/retry settings, API key.src/Publisher/Observers/UserSyncObserver.php — understand exactly what triggers a push before you turn it on.src/Receiver/Http/Middleware/ValidateSyncApiKey.php — the auth + loop-guard boundary. Read this before writing any custom receiver endpoint.src/Publisher/PublisherService.php and the UserTeamSync facade — the explicit-call surface you'll use for backfills and seeders.First useful use case: stand up two local Laravel apps, hub in publisher mode with a single SyncApp pointing at the secondary app's URL and bearer token, secondary app in receiver mode. Create a user on the hub, run a queue worker, and confirm the row lands on the secondary app with password_hash populated and a SyncLog entry on both sides. That one round trip exercises the observer, the job, the middleware, the receiving guard, and the logging — if all five behave, the rest is configuration.
Non-negotiable prerequisite: a running queue worker in publisher mode. The package's outbound work is queued jobs; without php artisan queue:work, nothing syncs and nothing errors loudly.
Observer-driven sync (the happy path). The UserSyncObserver fires on User model saves and only reacts to fields listed in publisher.sync_fields (default: email, role). Keep that list minimal. Every field you add is a field the receiver must handle, and the receiver's endpoints validate a fixed shape — adding a field to sync_fields without a corresponding receiver change is a silent no-op at best.
Explicit facade calls for anything deterministic. Don't rely on the observer for backfills, seeders, imports, or admin "resync this user" buttons. Call the UserTeamSync facade directly so the sync is an explicit, testable step rather than a side effect of a model save:
use MadBox99\UserTeamSync\Facades\UserTeamSync;
UserTeamSync::createUser($user); // fan out to all active SyncApps
Observers are for organic traffic; the facade is for operations you need to reason about and retry.
Backfill pattern. For migrating an existing user base, chunk the source query, dispatch in a Bus::batch, and make the receiver-side endpoints idempotent (unique index on email, upsert semantics). The jobs are retried from the LogsOutboundSync config, so a transient 500 will replay — if the receiver isn't idempotent, that replay creates duplicates. Test this deliberately by forcing a 500 on the first attempt and asserting no duplicate row.
Multi-app fan-out. With app_source = config, SyncApp records are static and version-controlled — good for a small, stable fleet. Switch to app_source = database when you need to add/rotate/disable apps at runtime; the model handles the encrypted api_key cast and exposes toAppArray() so PublisherService receives a consistent shape either way. If you build an admin UI over SyncApp, respect the encrypted cast — never write a raw key, and never pluck('api_key') expecting plaintext.
Receiver integration tips.
/api/.... Check them against your existing routes/api.php, global rate limiters, and any auth:sanctum group you've applied to the whole file. The bearer-token middleware is the package's own; your app-level auth must not shadow it.password_hash verbatim. The publisher hashes; the receiver writes the hash. Any Hash::make() on the receiving side double-hashes and locks the user out.create-team can attach a user in one call; remove-team-member detaches while keeping the account. Prefer toggle-user-active over deletion for deprovisioning — it's reversible and doesn't orphan team history.Observability. Every hop writes a SyncLog row. Wire an alert on inbound failures (non-2xx from your controllers) and on outbound jobs exhausting retries. Listen to the package's events to route into your existing error tracker rather than polling the table.
Testing. Pest + Orchestra Testbench is the package's own stack; mirror it. Http::fake() for outbound assertions, in-memory SQLite for receiver endpoints, and assert SyncLog rows as your primary assertion surface — they prove the sync happened, not just that a job was dispatched.
The infinite-loop guard is the thing you'll break. ValidateSyncApiKey sets app('user-team-sync.receiving', true) for the duration of an inbound request (with try/finally reset), and the observer checks that flag before dispatching. In both mode this is what stops an inbound write from echoing back out. If you write a custom receiver controller or middleware outside the package's, you must replicate both halves: set the flag, and use saveQuietly() on any model writes. Miss either and you get a sync loop that only manifests in both mode, usually in production.
Email changes are the subtle one. The observer maps an email change to a new_email key and sends the original email as the lookup identifier. If the receiver has already processed a prior change, or if the hub changes email twice before the queue drains, the lookup misses. Treat email sync as eventually consistent and reconcile with a nightly job if email identity matters for login.
Password drift is permanent and silent. Passwords are only synced through the package's endpoints. Any password reset, forced rotation, or "change password" flow that runs on the receiver alone will desync that user until the next explicit sync — which won't fix it, because the publisher doesn't know either. Decide up front which app owns credentials, and route resets there.
sync_fields is not additive for free. Adding a field to the config makes the observer notice it. It does not make the receiver accept it. Always ship the receiver-side request validation change before or with the config change, and deploy receivers first.
Migrations and table growth. sync_logs grows one row per hop and is never pruned by the package. Add a scheduled prune before it becomes a reporting table you can't truncate. Check that sync_apps and sync_logs are indexed on the columns you'll actually query (app_id, action, created_at) before volume makes the migration an outage.
API key rotation is coupled to APP_KEY. The api_key cast is encrypted, so keys are only readable under the APP_KEY that wrote them. Rotating APP_KEY, or copying a database between environments without the matching key, produces decryption failures on every sync. Plan key rotation as: write new keys, drain the queue, remove old keys — never rotate APP_KEY and SyncApp rows in the same window.
Queue silence is the default failure mode. A backed-up or stopped worker looks exactly like a healthy system until someone checks. Add a heartbeat check on queue depth and on the age of the newest SyncLog row.
Version pinning and upgrade discipline. v1.7.0 shipped with a compare link and no release notes. A minor bump is exactly where Laravel packages add migrations and deprecate config keys, so treat every upgrade as: read the diff, run migrations against a production-shaped schema clone, verify rollback, then stage. Pin explicitly and never let a composer update move this dependency unreviewed. Single maintainer, no disclosed security policy, no SLA — keep your integration surface thin enough that a fork stays a weekend.
Extension points worth knowing. The package dispatches events (hook for auditing/alerting), the SyncApp model supports a dynamic table name via config (useful for multi-tenant setups), and the jobs are ordinary classes you can rebind in the container to change retry/backoff behavior without forking. Reach for those three before you reach for a fork.
How can I help you explore Laravel packages today?