Skip to content

Changelog

All notable changes to this project are documented here. This project follows Semantic Versioning.

4.1.0

  • IdempotencyConflict now carries $e->priorResponse, the decoded JSON body of the prior successful keyed call for the same key. The catch block can replay it directly instead of re-fetching from upstream or querying integration_requests by hand. null when nothing recoverable is on file (no prior request row, the prior was logged as failed, response_data is null, or the persisted JSON is unparseable). The lookup runs only on the conflict path, with no overhead on the success path. The new constructor argument is added after $previous, so existing positional callers (new IdempotencyConflict($id, $key, $e)) keep working unchanged.
  • New Integration::getIdempotencyResponse(string $key): ?array method that backs the exception attribute. Useful when you want to probe for a prior response outside the catch flow, or recover from a key the exception isn't carrying for you. Returns the same shape (null when no recoverable prior is on file, the decoded response array otherwise) and scopes to the integration it's called on.

4.0.0

Rate limits are now window-aware, and a rate-limited sync item is deferred rather than failed. The GitHub adapter had declared 60 (GitHub's unauthenticated, per-hour figure) in a field the framework read as requests per minute, and when the limiter gave up waiting it threw an exception that failed the ProcessSyncItem job and wedged the sync. See the upgrade guide for the migration.

  • Breaking: HasScheduledSync::defaultRateLimit() returns ?Integrations\RateLimit instead of ?int. A RateLimit carries the request count, the window in seconds, and a fixed/sliding strategy. Build one with RateLimit::perHour(5000), RateLimit::perMinute(700), RateLimit::perDay(...), or RateLimit::per($limit, $seconds); append ->sliding() for an upstream that enforces a rolling window. null still means unlimited.
  • Breaking: RateLimitExceededException's constructor changed. It now carries retryAfterSeconds (when capacity is next expected) and an optional RateLimit, replacing the old requestsThisMinute / limit pair.
  • The RateLimiter enforces a fixed window by default, so a provider may spend its whole budget in a burst, the way a quota like GitHub's hourly limit behaves. Declare the limit ->sliding() for a rolling window instead. The previous implementation always approximated a sliding minute.
  • Inside a sync, hitting the rate limit now defers the item: ProcessSyncItem catches RateLimitExceededException and releases the job with the limiter's retry-after delay, so the run stays in flight. Previously the exception failed the item and stalled the cursor.
  • sync.item_tries now bounds genuine listener exceptions only; transient rate-limit deferrals no longer count against it. New sync.item_retry_window config (default 6h) is the absolute bound on how long an item may keep deferring.

3.0.0

Sync now tracks per-item completion. Cursor advancement waits for the items' listeners to finish, instead of moving on as soon as the events were dispatched. This closes a silent-data-loss gap: previously a queued listener that exhausted its retries left the item in failed_jobs while the cursor had already advanced past it, and once the item fell outside the overlap window it was never re-fetched. See the upgrade guide for the migration.

  • Breaking: HasScheduledSync::sync() and HasIncrementalSync::syncIncremental() no longer return a SyncResult. They now take a SyncSession as a second argument and return void. The provider enumerates items and hands each to $session->dispatch($event, $checkpointValue, $externalId) instead of dispatching events itself. HasScheduledSync also gains reduceCheckpoints(array): mixed; implement it directly, or use Integrations\Concerns\ReducesCheckpointsByMax for the common "max wins" reduction. Read the previous cursor with $session->cursor(); providers no longer write sync_cursor themselves.
  • Breaking: events handed to $session->dispatch() must extend Integrations\Sync\SyncItemEvent, and their listeners must not implement ShouldQueue. The framework's new ProcessSyncItem job is the queued unit, and it invokes listeners synchronously so the job's success reflects the listener's. A queued listener fails the item with SyncListenerMustNotBeQueuedException. Listeners that need async follow-up work should dispatch their own job.
  • Breaking: SyncResult is now @internal. The framework constructs it from a run's integration_sync_items rows and carries it on the new SyncCompleted event; adapters no longer build or return it.
  • New integration_sync_items table: one row per dispatched item, tracking pending / processing / success / failed / skipped. Requires running migrations. The sync flow also dispatches a Bus::batch, so Laravel's job_batches table must exist (php artisan queue:batches-table).
  • New canonical sync events. SyncCompleted fires once a run reconciles (carrying a SyncResult); SyncItemFailed fires when an item exhausts its retries. These replace the per-adapter aggregate and failure events.
  • New recovery commands. integrations:list-failed-items surfaces items needing attention, integrations:skip-sync-item skips an unrecoverable one so the cursor can move on, and integrations:advance-cursor re-reconciles a stuck run. integrations:prune now also prunes completed sync items (pruning.sync_items_days, default 30).
  • New config under integrations.sync: item_queue, item_tries, item_backoff, and max_items_per_batch.

2.5.1

  • RequestExecutor::persistRequest() now sanitizes non-UTF-8 byte sequences in both request_data and response_data before insert, replacing them with a [BINARY <length> bytes sha256=<hash>] marker. Previously, adapter resources that returned raw bytes (Zendesk attachments()->download(), GitHub assets()->download(), anything else handing Http::...->body() straight through a closure) crashed the INSERT with SQLSTATE[22007] ... Incorrect string value, because the columns are longText (utf8mb4) and MariaDB/MySQL reject bytes that don't decode as UTF-8. The audit row is still written with the marker for diagnostics. expires_at is nulled out on binary responses so the row never becomes a cache source. New Integrations\Support\BinaryGuard helper exposes the check. No schema change.

2.5.0

  • SyncIntegration::middleware() now calls ->dontRelease() on its WithoutOverlapping middleware. Previously, when a sibling sync held the lock, the duplicate dispatch was released with releaseAfter=0 (Laravel's default), which re-popped it instantly and burned through tries=3 in milliseconds, minting MaxAttemptsExceededException events on every overlap even though the actual sync was completing successfully. The schedule cycle (Schedule::command('integrations:sync')->everyMinute()) re-dispatches if the integration is still due, so dropping duplicates is information-free. See Scheduled syncs.
  • New integrations.sync.job_timeout config (default 1800s / 30 min). integrations:sync reads it and passes it to the dispatched SyncIntegration job. The job's hardcoded constructor default also bumps from 600s to 1800s so direct dispatchers (tests, custom flows) get the safer default. 10 minutes was tight for first-run backfills against incremental APIs (e.g. multi-page Zendesk ticket windows). With the dontRelease() fix above, a long-running sync now holds the lock for the full timeout before the next dispatch can try, so the timeout value matters more than it did under the thundering-herd path.
  • integrations.sync.lock_ttl default bumped from 600s to 1800s to match the new job_timeout. The lock must outlast the job that holds it; otherwise the lock auto-expires mid-sync and lets a sibling dispatch start running concurrently, which is exactly what WithoutOverlapping exists to prevent. If you've published this config and set a custom lock_ttl, raise it to at least your job_timeout.
  • Recommend cursor checkpointing for adapters with long-running incremental syncs. See Long-running syncs and cursor checkpointing and the adapter-side Sync pattern. Per item is best when the iterator exposes a per-item callback; per page is a cheap fallback for iterators that only surface page boundaries. Companion adapter-side fix landing in pocketarc/laravel-integrations-adapters.

2.4.1

  • ResponseHelper::normalize() now converts stdClass payloads to associative arrays before returning them as the parsed value, instead of passing the object through unchanged. Adapters bridging SDKs that call json_decode($body) without assoc=true (e.g. Zendesk\API\Http::send()) flowed stdClass trees straight into Spatie\LaravelData\Data::from(), where every Collection<int, T> element failed validation with "The tickets.0 field must be an array" and the request throws SchemaDriftException. Narrowed to stdClass so closures returning a typed object (e.g. a Data instance with no ->as() set) keep current pass-through semantics. Pairs with a matching SDK-boundary fix in pocketarc/laravel-integrations-adapters.

2.4.0

  • integration_mappings.external_id widened from 255 to 500 characters. The composite unique index (integration_id, external_id, internal_type) still fits under the InnoDB DYNAMIC 3072-byte ceiling, so no index strategy change. Covers real-world cases where adapter-bridged external IDs (e.g. attachment URLs flowing through the GitHub adapter) exceed 255 chars. See ID mapping. Existing deployments need a downstream ALTER migration since the canonical migration is the only one bumped; fresh installs get the new width on first migrate.
  • IntegrationCredentialCast::set() now throws InvalidArgumentException on anything other than null, an array, or a Spatie\LaravelData\Data instance, instead of silently returning null. Previously, factories that pre-encrypted credentials with Crypt::encryptString(json_encode(...)) would produce rows with credentials = NULL that later tripped credential-type guards in SDK clients on first use. Drop the manual encrypt and pass plain arrays; the cast handles encryption.

2.3.0

  • Idempotency collapses the 2.1 transport-level withIdempotencyKey($key) and the 2.2 application-level withReservation($key, $callback) into one fluent primitive on the request builder: $integration->at($endpoint)->withIdempotencyKey($key)->post(...). Every keyed call inserts a row in the new integration_idempotency_keys table before the closure runs; a second call with the same (integration_id, key) throws Integrations\Exceptions\IdempotencyConflict. The key is also passed to adapters via RequestContext so providers that implement SupportsIdempotency (Stripe, etc.) send it on the wire as a defense-in-depth backstop against intra-attempt SDK retries. Breaking changes: Integration::withReservation(), ReservationConflict, and the integration_idempotency_reservations table are removed; replace with withIdempotencyKey($key) on the fluent builder, the renamed IdempotencyConflict exception, and the integration_idempotency_keys table. The auto-UUID form (withIdempotencyKey() with no args) is gone, since keys must be application-meaningful and stable across retries; passing null is now a no-op rather than triggering UUID generation. The max key length is now 191 characters (was 64 on the transport side). The keyed call refuses to run inside a DB::transaction() and throws RuntimeException immediately, since an outer rollback would silently nuke the at-most-once row.

2.2.0

  • Application-level idempotency reservations: $integration->withReservation($key, $callback) reserves a (integration_id, key) row before running the callback, throws ReservationConflict if another caller already reserved that key, and releases the row if the callback throws. Complements the 2.1 transport-level withIdempotencyKey() for providers that don't natively dedupe (Zendesk, Postmark, etc.). Refuses to run inside a DB::transaction() because an outer rollback would also roll back the reservation INSERT and break at-most-once. New integration_idempotency_reservations table; integrations:prune sweeps rows older than pruning.reservations_days (default 90, matching requests_days). Superseded by the 2.3.0 collapse, above.

2.1.2

  • Closure docblock corrected on the fluent builder's terminal verbs (get(), post(), etc.), on Integration::request(), and on RequestExecutor::execute(). 2.1.1 used Closure(RequestContext=): mixed, which PHPStan reads contravariantly: an optional-arg signature means the wrapper might call the closure with no args, so a closure that requires the arg can't satisfy the type. The new declaration is a union, (Closure(): mixed)|(Closure(RequestContext): mixed), matching what the wrapper actually does (zero-arg or RequestContext arg, decided by reflection). Adapters with typed-arg closures now pass phpstan analyse. No runtime change.

2.1.1

  • Attempted PHPDoc fix for typed-arg adapter closures. The declaration shipped in this release (Closure(RequestContext=): mixed) is contravariantly wrong; skip to 2.1.2.

2.1.0

  • Idempotency keys as a first-class builder concern: ->withIdempotencyKey($key) on the fluent builder, with a UUID auto-generated when called with null. The key persists to the new integration_requests.idempotency_key column and is preserved across inner retry attempts so the upstream sees the same key on every try. New SupportsIdempotency marker contract; providers without it get a warning when callers attach a key, since the upstream won't dedupe.
  • Provider request IDs captured on integration_requests.provider_request_id. Adapters report via RequestContext::reportResponseMetadata(providerRequestId: ...) after the SDK call. Stripe captures Request-Id; GitHub captures X-GitHub-Request-Id plus rate-limit headers. Postmark and Zendesk surface nothing (their SDKs hide response headers).
  • Adaptive rate limiting: the RateLimiter honours Retry-After and X-RateLimit-Remaining: 0 signals when adapters report them, suppressing subsequent requests until the window clears. Falls back to the existing bucket logic when nothing's reported.
  • Circuit breaker per-integration. On by default with conservative thresholds (5 consecutive failures, 60s cooldown). Opens on 5xx / connection / RetryableException failures; 4xx (except 429) doesn't count. New non-retryable CircuitOpenException short-circuits before the rate limiter and retries. Configure under circuit_breaker.*.
  • SchemaDriftException replaces silent null returns in the request cache and the live-path Data hydration. When a Spatie Data class fails to hydrate a response (live or cache), the exception is thrown with the parsed payload and target class attached. Behaviour change: cached payloads that no longer hydrate now throw on first read instead of degrading invisibly.
  • New RequestContext argument optionally available to terminal-verb closures (fn (RequestContext $ctx) => ...). Gives the closure access to the resolved idempotency key and the metadata-reporting hook. Zero-arg closures continue to work unchanged.

2.0.0

  • Renamed the request API. The fluent to() / toAs() pair becomes at()->as(), and the standalone request() / requestAs() methods collapse into one request() with an optional $responseClass argument.

    • $integration->to($endpoint) is now $integration->at($endpoint).
    • $integration->toAs($endpoint, $class) is now $integration->at($endpoint)->as($class).
    • $integration->requestAs($endpoint, $method, $class, $callback, ...) is now $integration->request($endpoint, $method, $callback, $class, ...), with $class optional.
    • PendingRequest::as(class-string<Data> $class) is the new chain step for typing responses.

    See Making requests for the full builder.

1.9.1

  • Migration fix: the integration_mappings unique index now uses an explicit short name so the generated identifier stays within MySQL's 64-character limit. Previously the auto-generated name caused the migration to fail on MySQL.

1.9.0

  • integrations:install command: interactive installer that introspects a provider's credentialDataClass() / metadataDataClass() via reflection, prompts for required fields (masking secret-looking names), validates with the provider's rules, runs the health check if the provider implements HasHealthCheck, and upserts the Integration row. Non-interactive callers can supply every value via repeatable --credential=key=value / --metadata=key=value flags. Use --force to skip the overwrite and failed-health-check confirmations.

1.8.0

  • registerDefaults(): companion packages can auto-register their providers so users don't need to edit config after composer require. Defaults never override user-defined entries. See Building adapters for the recommended service provider pattern.

1.7.1

  • Testing fake: assertion methods now accept the METHOD:endpoint prefix form in the endpoint argument, matching how fake() registers responses. A prefix that conflicts with an explicit method: argument raises InvalidArgumentException instead of silently mismatching.

1.7.0

  • RetryableException: throw to mark an error as retryable, with optional retryAfterSeconds and maxAttempts. Takes priority over CustomizesRetry and default status-code logic. Updated retry decision chain.
  • resultData parameter on logOperation(): nullable JSON column for structured operation output, separate from metadata.
  • OperationStarted event: dispatched when an operation is logged with status processing.

1.6.0

  • Added upsertByExternalId(): resolve, create-or-update, and map in a single atomic call.
  • Added resolveMappings(): batch-resolve multiple external IDs in two queries instead of 2N.
  • resolveMapping(), resolveMappings(), and upsertByExternalId() now return properly generic types (?Ticket instead of ?Model).
  • Testing fake: wildcard endpoint matching (tickets/*.json), respecting path segment boundaries.
  • Testing fake: method-aware fakes (GET:endpoint vs PUT:endpoint).
  • Testing fake: integration-scoped fakes via forIntegration() fluent API.
  • Assertion methods now support optional method and integrationId filters.

1.5.0

  • Automatic detection and honoring of Retry-After headers (capped by config, default 10 minutes). 429 falls back to a fixed 30s only when Retry-After is absent.
  • Integration providers can customize retryability and delay decisions via CustomizesRetry.
  • New retry.retry_after_max_seconds config setting to cap honored Retry-After duration (default 600s).

1.4.0

  • Added a typed request API with typed/untyped flows, typed response reconstruction, a request executor (caching, retries, rate limiting, stale fallback) and a request cache.

1.3.0

  • Added CI pipeline.
  • Added stricter PHPStan rules and safe function wrappers.
  • Confirmed PHP 8.2+ support (since Laravel 11/12 require 8.2 at a minimum).

1.2.0

  • Sync improvements, webhook overhaul, and opinionated defaults.

1.1.0

  • Added SyncResult return type.
  • Added per-provider queues.
  • Added rate limit backoff.
  • Improved health notifications.

1.0.0

Initial release.