Skip to content

Upgrade guide

This project follows Semantic Versioning. Minor and patch releases will never contain breaking changes.

4.x to 5.0

5.0 makes the circuit breaker an availability detector driven by a single failure classifier, switches its default strategy, and adds runtime overrides. Most apps need no code changes; the work is in custom providers and any direct callers of Integration::recordFailure().

Why

The breaker and health tracking each decided independently what counted as a "failure", and both counted too much. An HTTP 429 (the upstream throttling you) and a 4xx (a malformed request from one caller) would trip the breaker and degrade health, even though the dependency was healthy and answering. One bad caller could pull an integration offline for everyone sharing it. SDK exceptions the breaker couldn't read were counted as outages by accident.

5.0 routes every failure through one FailureClassifier whose verdict feeds both subsystems. Only genuine upstream faults (5xx except 501, connection errors, timeouts) count; throttles and client errors don't.

1. Add the new columns

5.0 adds four columns to the integrations table for runtime overrides (circuit_override, circuit_override_until, rate_limit_override, rate_limit_override_until).

A fresh install gets them from the published baseline migration. An existing deployment has already run that migration, so add a downstream migration that appends the columns (the canonical migration is the only one bumped, matching how earlier schema changes shipped):

php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Integrations\Support\Config;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table(Config::tablePrefix().'s', function (Blueprint $table): void {
            $table->string('circuit_override')->nullable();
            $table->timestamp('circuit_override_until')->nullable();
            $table->json('rate_limit_override')->nullable();
            $table->timestamp('rate_limit_override_until')->nullable();
        });
    }
};

Then run php artisan migrate.

2. If you call Integration::recordFailure() directly

It now takes a FailureClass argument. The framework calls it for you inside the request pipeline, so this only matters for direct callers (custom health-check flows, tests).

Before (4.x):

php
$integration->recordFailure();

After (5.0):

php
use Integrations\Enums\FailureClass;

$integration->recordFailure(FailureClass::Upstream);

Pass the class that matches the failure: Upstream for a genuine outage, Throttle for a rate limit, Client for a 4xx, Unknown when there's no signal. Only Upstream degrades health.

3. Choose your breaker strategy

The default strategy changed from consecutive-count to rate (failure percentage over a window). If you relied on "opens after N consecutive failures", set it back explicitly:

php
// config/integrations.php
'circuit_breaker' => [
    'strategy' => 'count', // 4.x behaviour
    'threshold' => 5,
],

The rate strategy can only trip once minimum_requests (default 10) have landed in the window, so a low-volume integration may never reach the floor. Use 'count' for volume-independent tripping. See Strategies.

4. Custom providers: classify SDK failures (optional)

Core reads Laravel/Guzzle/Symfony exceptions and duck-types the common SDK status accessors, so most providers need nothing. If your SDK signals failures in a way core can't see (a throttle that arrives as a generic 403, a connection error with no HTTP status), implement ClassifiesFailures:

php
use Integrations\Contracts\ClassifiesFailures;
use Integrations\Enums\FailureClass;

public function classifyFailure(\Throwable $e): ?FailureClass
{
    if ($e instanceof AcmeRateLimitException) {
        return FailureClass::Throttle;
    }

    return null; // defer to the default classifier
}

The verdict drives retries too (Upstream/Throttle are retried), so one method covers breaker, health, and retry behaviour.

Behaviour change to note

A 429 or 4xx no longer degrades health or trips the breaker. If you depended on repeated 4xx eventually auto-disabling an integration (via health.disabled_after), that no longer happens: only upstream faults count toward the disable threshold. This is the intended fix, but worth knowing if your monitoring assumed the old behaviour.

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):

php
public function defaultRateLimit(): ?int
{
    return 83; // meant to approximate ~5000/hour
}

After (4.0):

php
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.

bash
# 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 migrate

2. 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):

php
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):

php
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 ProcessSyncItem job'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 the integration_sync_items rows.
  • No cursor handling. Pass each item's checkpointValue; the framework reduces the run's checkpoints into the next sync_cursor via reduceCheckpoints().
  • reduceCheckpoints() is required. use ReducesCheckpointsByMax for 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:

php
use Illuminate\Foundation\Events\Dispatchable;

class ItemSynced
{
    use Dispatchable;

    public function __construct(
        public readonly Integration $integration,
        public readonly ItemData $item,
    ) {}
}

After:

php
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:

php
use Illuminate\Contracts\Queue\ShouldQueue;

class IngestItem implements ShouldQueue
{
    public function handle(ItemSynced $event): void
    {
        // heavy work...
    }
}

After:

php
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.x3.0
{Adapter}SyncCompletedIntegrations\Events\SyncCompleted
{Adapter}{ItemType}SyncFailedIntegrations\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:

bash
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 one

See Scheduled syncs for the full picture.