Upgrade guide
This project follows Semantic Versioning. Minor and patch releases will never contain breaking changes.
3.x to 4.0
4.0 makes rate limits window-aware and stops a throttled sync from failing. Two breaking changes, both small.
Why
HasScheduledSync::defaultRateLimit() returned a bare ?int the framework read as requests per minute. A per-minute integer can't express an hourly budget (5,000/hour is not 83/minute, because the hourly budget can be spent in a burst), so the unit was ambiguous and easy to get wrong. And when the limiter gave up waiting it threw an exception that failed the ProcessSyncItem job; since 3.0 a failed item holds the cursor, so a brief throttle could wedge the whole sync.
4.0 replaces the integer with a RateLimit value object that names the window, and defers (re-queues) rate-limited sync items instead of failing them.
1. Update defaultRateLimit()
If you have a custom provider implementing HasScheduledSync (or HasIncrementalSync), change defaultRateLimit() to return ?Integrations\RateLimit.
Before (3.x):
public function defaultRateLimit(): ?int
{
return 83; // meant to approximate ~5000/hour
}After (4.0):
use Integrations\RateLimit;
public function defaultRateLimit(): ?RateLimit
{
return RateLimit::perHour(5000);
}Pick the constructor that matches the upstream's real limit: perMinute(), perHour(), perDay(), or per($limit, $seconds). Append ->sliding() if the API enforces a rolling window rather than a budget that resets on a fixed boundary. Return null for no limit, as before.
2. If you construct RateLimitExceededException
Its constructor changed from ($integration, $requestsThisMinute, $limit) to ($integration, $retryAfterSeconds, ?RateLimit $rateLimit = null). The framework throws it for you, so this only matters if you construct it directly (in tests, say).
Nothing else changes
The rate-limit deferral inside syncs needs no action; ProcessSyncItem handles it. The new sync.item_retry_window config has a sensible 6-hour default. Official adapters (pocketarc/laravel-integrations-adapters) ship the matching change in their next major; bump both together.
2.x to 3.0
3.0 reworks how scheduled syncs advance the cursor. The provider contract changes, per-item events get a base class, and there's a new table. This guide covers every change you need to make.
Why
In 2.x a sync dispatched an event per item and advanced the cursor as soon as the events were dispatched. If a consumer's listener was queued (implements ShouldQueue) and later exhausted its retries, the item sat in failed_jobs while the cursor had already moved past it. Once the item fell outside the overlap window, nothing re-fetched it: silent data loss.
In 3.0 the framework wraps each item in a queued ProcessSyncItem job, runs the listeners inside it, and only advances the cursor once every item's job has succeeded. A failed item stops the cursor at it until it's resolved.
1. Run the new migrations
3.0 adds the integration_sync_items table. It also dispatches a Bus::batch, which needs Laravel's job_batches table, and records exhausted item jobs in failed_jobs.
# If you don't already have them:
php artisan queue:batches-table
php artisan queue:failed-table
# Then publish + run this package's new migration:
php artisan vendor:publish --tag=integrations-migrations
php artisan migrate2. Update your provider
sync() and syncIncremental() change signature: they take a SyncSession and return void. Instead of dispatching events and returning a SyncResult, the provider hands each item to $session->dispatch().
Before (2.x):
use Integrations\Sync\SyncResult;
public function syncIncremental(Integration $integration, mixed $cursor): SyncResult
{
$since = $cursor ?? now()->subDay()->toIso8601String();
$success = 0;
$safeCursor = $since;
foreach ($this->fetchItems($since) as $item) {
try {
ItemSynced::dispatch($integration, $item);
$success++;
$safeCursor = max($safeCursor, $item->updated_at);
} catch (\Throwable $e) {
ItemSyncFailed::dispatch($integration, $item, $e);
}
}
return new SyncResult($success, 0, now(), $safeCursor);
}After (3.0):
use Integrations\Concerns\ReducesCheckpointsByMax;
use Integrations\Sync\SyncSession;
class MyProvider implements IntegrationProvider, HasIncrementalSync
{
use ReducesCheckpointsByMax;
public function syncIncremental(Integration $integration, SyncSession $session): void
{
$since = $session->cursor() ?? now()->subDay()->toIso8601String();
foreach ($this->fetchItems($since) as $item) {
$session->dispatch(
new ItemSynced($integration, $item),
checkpointValue: $item->updated_at,
externalId: (string) $item->id,
);
}
}
}What changed:
- No try/catch. A listener failure is now the
ProcessSyncItemjob's failure; the framework retries it and records it. Don't catch it in the provider. - No counting and no
SyncResult. The framework derives success/failure counts from theintegration_sync_itemsrows. - No cursor handling. Pass each item's
checkpointValue; the framework reduces the run's checkpoints into the nextsync_cursorviareduceCheckpoints(). reduceCheckpoints()is required.use ReducesCheckpointsByMaxfor the common case (max of ISO-8601 timestamps / lexicographic ids), or implement it directly for non-comparable cursors.sync()changes the same way:sync(Integration $integration, SyncSession $session): void.
3. Update per-item events
Events handed to $session->dispatch() must extend Integrations\Sync\SyncItemEvent.
Before:
use Illuminate\Foundation\Events\Dispatchable;
class ItemSynced
{
use Dispatchable;
public function __construct(
public readonly Integration $integration,
public readonly ItemData $item,
) {}
}After:
use Integrations\Sync\SyncItemEvent;
class ItemSynced extends SyncItemEvent
{
public function __construct(
public readonly Integration $integration,
public readonly ItemData $item,
) {}
}SyncItemEvent already pulls in Dispatchable and SerializesModels, so drop those traits from your event.
4. Make sync-item listeners synchronous
This is the consumer-facing change. A listener for a SyncItemEvent must not implement ShouldQueue. The ProcessSyncItem job is already the queued unit; a queued listener would let it report success before the listener ran. ProcessSyncItem rejects a queued listener with SyncListenerMustNotBeQueuedException.
Before:
use Illuminate\Contracts\Queue\ShouldQueue;
class IngestItem implements ShouldQueue
{
public function handle(ItemSynced $event): void
{
// heavy work...
}
}After:
class IngestItem
{
public function handle(ItemSynced $event): void
{
// The sync-critical work runs here, synchronously.
$local = MyModel::updateOrCreate(/* ... */);
// Genuinely async follow-up work? Dispatch your own job.
GenerateSummary::dispatch($local);
}
}Each attempt re-runs the listener, so it must be idempotent: use upsertByExternalId() / updateOrCreate().
5. Replace removed events
The per-adapter aggregate and failure events are gone. Switch to the canonical core events:
| 2.x | 3.0 |
|---|---|
{Adapter}SyncCompleted | Integrations\Events\SyncCompleted |
{Adapter}{ItemType}SyncFailed | Integrations\Events\SyncItemFailed |
SyncCompleted carries the Integration and a SyncResult. SyncItemFailed carries the Integration, the IntegrationSyncItem row, and the Throwable.
6. SyncResult is now internal
SyncResult is @internal in 3.0: the framework builds it and hands it to the SyncCompleted event. If you constructed SyncResult directly (in tests, custom flows), read it off the SyncCompleted event instead.
Recovery
Once on 3.0, a failed item stops the cursor until you resolve it:
php artisan integrations:list-failed-items # see what needs attention
php artisan queue:retry <uuid> # fix the cause, retry the item
php artisan integrations:skip-sync-item <id> # or skip an unrecoverable oneSee Scheduled syncs for the full picture.