Track the sync status of any Eloquent model against external systems — CRMs, ERPs, e-commerce platforms, or any third-party API. Record external IDs, per-source metadata, and sync timestamps, then query your models by what they're synced to.
$user->syncData('salesforce')->markAsSynced('SF-123456', ['account_type' => 'customer']);
$user->syncData('salesforce')->isSynced(); // true
$user->syncData('salesforce')->external_id; // 'SF-123456'
User::hasExternalId('salesforce')->get(); // every user synced to SalesforceThis fork builds on the original andreagroferreira/laravel-sync-tracker by @andreagroferreira, with breaking changes to properly support multiple sync sources and a simpler, source-scoped API.
- The API is source-scoped.
$model->syncData($source)returns the tracking entity for one source, and every read and write happens on it. You no longer thread a$sourceargument through each call. - Sync metadata is separate from sync state. You can
markAsSynced()without touching metadata, andmergeSyncMetadata()to update a few keys instead of overwriting everything. - The old model methods were removed, not just deprecated. They can be polyfilled by adding the
DeprecatedSyncTrackerMethodstrait — see Upgrading. - The active examples below cover trait usage, which is how we use it. PRs with tests for the facade are welcome.
Upgrading from v1? v2 is a breaking change (the
sourcecolumn becomesNOT NULLand existing data needs manual cleanup). Read UPGRADE.md before migrating — it includes the full API mapping, the data-migration SQL, and a paste-ready AI migration prompt.
- PHP 8.2+
- Laravel 11, 12, or 13
- 🔄 Track sync status of any Eloquent model against external systems
- 🧩 Multiple sources per model — track the same record in Salesforce, HubSpot, and your ERP independently
- 🔍 Find models by external ID and source
- 📊 Per-source metadata for audit trails, with set-or-merge semantics
- 🕒 Automatic lifecycle timestamps for create / update / delete events
- 🔌 Query scopes to filter models by what they're synced to
- 🛠️ Configurable per model and globally
- Installation
- Configuration
- Basic Usage
- Advanced Usage
- Real-world Examples
- Events
- Custom Implementations
- Upgrading from v1
- Testing
- Changelog
- Contributing
- Security
- Credits
- License
Install via Composer:
composer require andreagroferreira/laravel-sync-trackerThis fork isn't on Packagist, so it installs from the GitHub repo. Paste this into your coding agent:
Add the steadfast-collective fork of andreagroferreira/laravel-sync-tracker to this Laravel app. It's hosted on a GitHub repo, not on Packagist. Add a VCS repository entry for https://github.com/steadfast-collective/laravel-sync-tracker to composer.json, then require it with `composer require andreagroferreira/laravel-sync-tracker:dev-fix/multi-source`. Publish the migrations (tag "migrations" on the WizardingCode\FlowNetwork\SyncTracker\SyncTrackerServiceProvider provider) and run `php artisan migrate`. Publishing the config (tag "config") is optional — only do it if the defaults need changing.
Publish the migrations and run them:
php artisan vendor:publish --provider="WizardingCode\FlowNetwork\SyncTracker\SyncTrackerServiceProvider" --tag="migrations"
php artisan migratePublishing the config is optional — the package works on its defaults. Only publish it if you need to change them:
php artisan vendor:publish --provider="WizardingCode\FlowNetwork\SyncTracker\SyncTrackerServiceProvider" --tag="config"The published config file (config/sync-tracker.php):
return [
// The table name used to store sync tracking information.
'table_name' => 'sync_tracked_entities',
// Whether a tracking call may omit the sync source. When enabled (the default),
// omitting the source resolves to the 'default' source. Disable it for strict
// mode when working with multiple sources, so a missing source throws instead of
// silently using 'default'. An empty source string ('') always throws.
'allow_empty_source' => true,
// Default automatic lifecycle tracking.
'default_tracking' => [
'track_created' => true,
'track_updated' => true,
'track_deleted' => true,
],
// Per-model overrides for the lifecycle tracking above.
'models' => [
// App\Models\User::class => [
// 'track_created' => true,
// 'track_updated' => false,
// 'track_deleted' => true,
// ],
],
];Add the HasSyncTracking trait to your model:
use WizardingCode\FlowNetwork\SyncTracker\Traits\HasSyncTracking;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
use HasSyncTracking;
}Call syncData() to get the tracking entity, then read and write everything through it:
$user = User::find(1);
// Mark the model as synced (external ID + optional metadata)
$user->syncData()->markAsSynced('external-123', ['meta' => 'data']);
// Check whether it's synced
if ($user->syncData()->isSynced()) {
// ...
}
// Read sync information — the entity is a plain Eloquent model
$externalId = $user->syncData()->external_id;
$metadata = $user->syncData()->metadata;
$syncedAt = $user->syncData()->synced_at;
// Find a model by its external ID
$user = User::findByExternalId('external-123');Calls that omit the source use the
'default'source. Tracking a model in more than one system? See Multiple Source Systems.
Handy when you don't have the model's trait in hand:
use WizardingCode\FlowNetwork\SyncTracker\Facades\SyncTracker;
// Mark a model as synced: (model, externalId, source, metadata)
SyncTracker::markAsSynced($model, 'external-123', metadata: ['meta' => 'data']);
// Check if synced
if (SyncTracker::isSynced($model)) {
// ...
}
// Get the tracking row
$syncInfo = SyncTracker::getSyncInfo($model);Store arbitrary metadata with setSyncMetadata (replace) and mergeSyncMetadata (patch):
$user = User::find(1);
// Replace all metadata
$user->syncData()->setSyncMetadata([
'remote_modified_at' => $responseData['modified_at'],
'direction' => 'pull',
'status' => 'success',
]);
// Patch a few keys — remote_modified_at is left untouched
$user->syncData()->mergeSyncMetadata([
'direction' => 'push',
'status' => 'failed',
]);The examples so far used the 'default' source. Pass a source name to syncData() to track the same model in several systems independently — each source keeps its own external ID, metadata, and timestamps:
$user = User::find(1);
// Synced with Salesforce
$user->syncData('salesforce')->markAsSynced('SF-123456', [
'last_sync' => now(),
'account_type' => 'customer',
]);
// ...and elsewhere, synced with HubSpot
$user->syncData('hubspot')->markAsSynced('HS-789012', [
'contact_owner' => 'jane.doe@example.com',
'lead_score' => 85,
]);
// All real sync rows for this user (excluding the automatic lifecycle row)
$syncTrackers = $user->syncTrackers()->withoutLifecycle()->get();
// Each source's external ID
$salesforceId = $user->syncData('salesforce')->external_id;
$hubspotId = $user->syncData('hubspot')->external_id;Lifecycle rows: create/update/delete timestamps are recorded on a separate row under the
'_lifecycle'sentinel source, so they never clobber a real source's data. Filter it out of listings with thewithoutLifecycle()scope.
The trait ships with the hasExternalId scope. It filters to models that have a tracking row carrying a non-null external_id (which also excludes the automatic lifecycle rows). Pass a source to require a sync to that specific one:
// Synced to any source
Product::hasExternalId()->get();
// Synced specifically to 'shopify'
Product::hasExternalId('shopify')->get();For finer-grained filtering, query the syncTrackers() relation directly (e.g. whereHas('syncTrackers', ...)) — its rows carry source, synced_at, and metadata columns.
class ProductSyncService
{
public function syncToShopify(Product $product)
{
$shopifyId = $product->syncData('shopify')->external_id;
if ($shopifyId) {
$response = $this->shopifyClient->updateProduct($shopifyId, [
'title' => $product->name,
'price' => $product->price,
'inventory_quantity' => $product->stock,
]);
} else {
$response = $this->shopifyClient->createProduct([
'title' => $product->name,
'price' => $product->price,
'inventory_quantity' => $product->stock,
]);
$shopifyId = $response['id'];
}
$product->syncData('shopify')->markAsSynced($shopifyId, [
'shopify_handle' => $response['handle'],
'variants_synced' => count($response['variants']),
'images_synced' => count($response['images']),
'shopify_updated_at' => $response['updated_at'],
'inventory_tracked' => true,
]);
return $response;
}
}Use the stored synced_at to tell whether the remote record changed since your last sync, and stamp the outcome into metadata:
class ContactSyncService
{
public function syncWithCrm(User $user)
{
$tracker = $user->syncData('crm');
$externalId = $tracker->external_id;
$fields = $user->only(['name', 'email', 'phone']);
$remoteChangedSinceLastSync = false;
if ($externalId) {
$crmData = $this->crmClient->getContact($externalId);
$remoteChangedSinceLastSync = Carbon::parse($crmData['updated_at'])->gt($tracker->synced_at);
$result = $this->crmClient->updateContact($externalId, $fields);
} else {
$result = $this->crmClient->createContact($fields);
}
$user->syncData('crm')->markAsSynced($result['id'], [
'crm_updated_at' => $result['updated_at'],
'conflict_detected' => $remoteChangedSinceLastSync,
]);
return $result;
}
}On failure, record the error in metadata without touching synced_at (so the row still reads as "last synced at X"):
try {
$response = $this->apiClient->createOrUpdate($product);
$product->syncData('erp')->markAsSynced($response['id'], [
'sync_status' => 'success',
'last_successful_sync' => now(),
]);
} catch (ApiException $e) {
$tracker = $product->syncData('erp');
$retryCount = ($tracker->metadata['retry_count'] ?? 0) + 1;
// mergeSyncMetadata patches keys without overwriting external_id or synced_at
$tracker->mergeSyncMetadata([
'sync_status' => 'failed',
'error_message' => $e->getMessage(),
'error_code' => $e->getCode(),
'retry_count' => $retryCount,
'last_attempt' => now()->toIso8601String(),
]);
if ($retryCount < 5) {
SyncRetryJob::dispatch($product)->delay(now()->addMinutes(30));
}
}// When a local change is made, flag it for upstream sync
$product = Product::find(1);
$product->update(['price' => 29.99]);
$product->syncData('erp')->mergeSyncMetadata([
'needs_upstream_sync' => true,
'local_changes' => ['price' => 29.99],
]);
// When receiving webhooks from an external system
public function handleExternalUpdate(Request $request)
{
$externalId = $request->input('id');
$source = 'erp';
$product = SyncTracker::findByExternalId($externalId, $source, Product::class);
if ($product) {
$product->update([
'name' => $request->input('name'),
'sku' => $request->input('sku'),
]);
$product->syncData($source)->markAsSynced($externalId, [
'sync_type' => 'downstream',
'webhook_id' => $request->input('webhook_id'),
'external_updated_at' => $request->input('updated_at'),
]);
}
}EntitySynced is dispatched automatically on every successful sync — whether you sync via the trait (syncData(...)->markAsSynced(...)) or the facade. SyncFailed is provided for you to dispatch from your own failure handling.
// In your EventServiceProvider
protected $listen = [
\WizardingCode\FlowNetwork\SyncTracker\Events\EntitySynced::class => [
\App\Listeners\HandleEntitySynced::class,
],
\WizardingCode\FlowNetwork\SyncTracker\Events\SyncFailed::class => [
\App\Listeners\HandleSyncFailed::class,
],
];EntitySynced carries $model and $syncInfo (the SyncTrackedEntity):
namespace App\Listeners;
use WizardingCode\FlowNetwork\SyncTracker\Events\EntitySynced;
class HandleEntitySynced
{
public function handle(EntitySynced $event)
{
$model = $event->model;
$syncInfo = $event->syncInfo;
if ($model instanceof \App\Models\CriticalEntity) {
\Notification::route('slack', config('services.slack.webhook_url'))
->notify(new \App\Notifications\EntitySynced($model, $syncInfo));
}
\Cache::tags([$model->getTable()])->flush();
}
}SyncFailed carries $model, $source, $exception, and $metadata.
public function getSyncStatus(Request $request)
{
$request->validate([
'model_type' => 'required|string',
'model_id' => 'required',
'source' => 'required|string',
]);
$modelClass = $this->getModelClassFromType($request->model_type);
$model = $modelClass::find($request->model_id);
if (! $model) {
return response()->json(['error' => 'Model not found'], 404);
}
// syncTrackers() is the live relation; ->first() returns null when not synced
$syncInfo = $model->syncTrackers()->where('source', $request->source)->first();
if (! $syncInfo) {
return response()->json([
'sync_status' => 'not_synced',
'model_type' => $request->model_type,
'model_id' => $request->model_id,
'source' => $request->source,
]);
}
return response()->json([
'sync_status' => $syncInfo->synced_at ? 'synced' : 'pending',
'external_id' => $syncInfo->external_id,
'last_synced_at' => $syncInfo->synced_at,
'last_updated_at' => $syncInfo->updated_at,
'metadata' => $syncInfo->metadata,
]);
}v2 moved the API onto the SyncTrackedEntity itself and made source a required, indexed column. Existing NULL-source rows must be deduplicated and backfilled by hand before you migrate — the package does not do it for you.
Read UPGRADE.md for:
- the full deprecated → replacement method mapping,
- the data-cleanup SQL to run before
php artisan migrate, - a paste-ready prompt for migrating an app with an AI coding agent.
If you want the old model methods (markAsSynced(), getExternalId(), getSyncMetadata(), the syncTracking relation, etc.) to keep working as deprecated delegates while you migrate, add the add-on trait alongside HasSyncTracking:
use WizardingCode\FlowNetwork\SyncTracker\Traits\HasSyncTracking;
use WizardingCode\FlowNetwork\SyncTracker\Traits\DeprecatedSyncTrackerMethods;
class User extends Model
{
use HasSyncTracking;
use DeprecatedSyncTrackerMethods; // optional, for backwards compatibility
}composer testSee CHANGELOG.md for what's changed recently.
See CONTRIBUTING.md for details.
If you discover a security issue in this fork, please email sami@steadfastcollective.com rather than using the issue tracker.
- André Ferreira — original author
- Steadfast Collective — fork maintainer
- Sami Walbury — v2 source-scoped multi-source API, per-source metadata, Laravel 11–13 / PHP 8.5 support, and Postgres/MySQL/morph-map fixes
- All Contributors
The MIT License (MIT). See the License File for details.