Changelog
All notable changes to this project are documented here. This project follows Semantic Versioning.
4.1.0
IdempotencyConflictnow 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 queryingintegration_requestsby hand.nullwhen nothing recoverable is on file (no prior request row, the prior was logged as failed,response_datais 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): ?arraymethod 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 (nullwhen 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\RateLimitinstead of?int. ARateLimitcarries the request count, the window in seconds, and a fixed/sliding strategy. Build one withRateLimit::perHour(5000),RateLimit::perMinute(700),RateLimit::perDay(...), orRateLimit::per($limit, $seconds); append->sliding()for an upstream that enforces a rolling window.nullstill means unlimited. - Breaking:
RateLimitExceededException's constructor changed. It now carriesretryAfterSeconds(when capacity is next expected) and an optionalRateLimit, replacing the oldrequestsThisMinute/limitpair. - The
RateLimiterenforces 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:
ProcessSyncItemcatchesRateLimitExceededExceptionand 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_triesnow bounds genuine listener exceptions only; transient rate-limit deferrals no longer count against it. Newsync.item_retry_windowconfig (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()andHasIncrementalSync::syncIncremental()no longer return aSyncResult. They now take aSyncSessionas a second argument and returnvoid. The provider enumerates items and hands each to$session->dispatch($event, $checkpointValue, $externalId)instead of dispatching events itself.HasScheduledSyncalso gainsreduceCheckpoints(array): mixed; implement it directly, oruse Integrations\Concerns\ReducesCheckpointsByMaxfor the common "max wins" reduction. Read the previous cursor with$session->cursor(); providers no longer writesync_cursorthemselves. - Breaking: events handed to
$session->dispatch()must extendIntegrations\Sync\SyncItemEvent, and their listeners must not implementShouldQueue. The framework's newProcessSyncItemjob is the queued unit, and it invokes listeners synchronously so the job's success reflects the listener's. A queued listener fails the item withSyncListenerMustNotBeQueuedException. Listeners that need async follow-up work should dispatch their own job. - Breaking:
SyncResultis now@internal. The framework constructs it from a run'sintegration_sync_itemsrows and carries it on the newSyncCompletedevent; adapters no longer build or return it. - New
integration_sync_itemstable: one row per dispatched item, trackingpending/processing/success/failed/skipped. Requires running migrations. The sync flow also dispatches aBus::batch, so Laravel'sjob_batchestable must exist (php artisan queue:batches-table). - New canonical sync events.
SyncCompletedfires once a run reconciles (carrying aSyncResult);SyncItemFailedfires when an item exhausts its retries. These replace the per-adapter aggregate and failure events. - New recovery commands.
integrations:list-failed-itemssurfaces items needing attention,integrations:skip-sync-itemskips an unrecoverable one so the cursor can move on, andintegrations:advance-cursorre-reconciles a stuck run.integrations:prunenow also prunes completed sync items (pruning.sync_items_days, default 30). - New config under
integrations.sync:item_queue,item_tries,item_backoff, andmax_items_per_batch.
2.5.1
RequestExecutor::persistRequest()now sanitizes non-UTF-8 byte sequences in bothrequest_dataandresponse_databefore insert, replacing them with a[BINARY <length> bytes sha256=<hash>]marker. Previously, adapter resources that returned raw bytes (Zendeskattachments()->download(), GitHubassets()->download(), anything else handingHttp::...->body()straight through a closure) crashed the INSERT withSQLSTATE[22007] ... Incorrect string value, because the columns arelongText(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_atis nulled out on binary responses so the row never becomes a cache source. NewIntegrations\Support\BinaryGuardhelper exposes the check. No schema change.
2.5.0
SyncIntegration::middleware()now calls->dontRelease()on itsWithoutOverlappingmiddleware. Previously, when a sibling sync held the lock, the duplicate dispatch was released withreleaseAfter=0(Laravel's default), which re-popped it instantly and burned throughtries=3in milliseconds, mintingMaxAttemptsExceededExceptionevents 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_timeoutconfig (default 1800s / 30 min).integrations:syncreads it and passes it to the dispatchedSyncIntegrationjob. 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 thedontRelease()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_ttldefault bumped from 600s to 1800s to match the newjob_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 whatWithoutOverlappingexists to prevent. If you've published this config and set a customlock_ttl, raise it to at least yourjob_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 convertsstdClasspayloads to associative arrays before returning them as the parsed value, instead of passing the object through unchanged. Adapters bridging SDKs that calljson_decode($body)withoutassoc=true(e.g.Zendesk\API\Http::send()) flowedstdClasstrees straight intoSpatie\LaravelData\Data::from(), where everyCollection<int, T>element failed validation with "The tickets.0 field must be an array" and the request throwsSchemaDriftException. Narrowed tostdClassso 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 inpocketarc/laravel-integrations-adapters.
2.4.0
integration_mappings.external_idwidened 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 downstreamALTERmigration since the canonical migration is the only one bumped; fresh installs get the new width on first migrate.IntegrationCredentialCast::set()now throwsInvalidArgumentExceptionon anything other thannull, an array, or aSpatie\LaravelData\Datainstance, instead of silently returningnull. Previously, factories that pre-encrypted credentials withCrypt::encryptString(json_encode(...))would produce rows withcredentials = NULLthat 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-levelwithReservation($key, $callback)into one fluent primitive on the request builder:$integration->at($endpoint)->withIdempotencyKey($key)->post(...). Every keyed call inserts a row in the newintegration_idempotency_keystable before the closure runs; a second call with the same(integration_id, key)throwsIntegrations\Exceptions\IdempotencyConflict. The key is also passed to adapters viaRequestContextso providers that implementSupportsIdempotency(Stripe, etc.) send it on the wire as a defense-in-depth backstop against intra-attempt SDK retries. Breaking changes:Integration::withReservation(),ReservationConflict, and theintegration_idempotency_reservationstable are removed; replace withwithIdempotencyKey($key)on the fluent builder, the renamedIdempotencyConflictexception, and theintegration_idempotency_keystable. The auto-UUID form (withIdempotencyKey()with no args) is gone, since keys must be application-meaningful and stable across retries; passingnullis 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 aDB::transaction()and throwsRuntimeExceptionimmediately, 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, throwsReservationConflictif another caller already reserved that key, and releases the row if the callback throws. Complements the 2.1 transport-levelwithIdempotencyKey()for providers that don't natively dedupe (Zendesk, Postmark, etc.). Refuses to run inside aDB::transaction()because an outer rollback would also roll back the reservation INSERT and break at-most-once. Newintegration_idempotency_reservationstable;integrations:prunesweeps rows older thanpruning.reservations_days(default 90, matchingrequests_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.), onIntegration::request(), and onRequestExecutor::execute(). 2.1.1 usedClosure(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 orRequestContextarg, decided by reflection). Adapters with typed-arg closures now passphpstan 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 withnull. The key persists to the newintegration_requests.idempotency_keycolumn and is preserved across inner retry attempts so the upstream sees the same key on every try. NewSupportsIdempotencymarker 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 viaRequestContext::reportResponseMetadata(providerRequestId: ...)after the SDK call. Stripe capturesRequest-Id; GitHub capturesX-GitHub-Request-Idplus rate-limit headers. Postmark and Zendesk surface nothing (their SDKs hide response headers). - Adaptive rate limiting: the
RateLimiterhonoursRetry-AfterandX-RateLimit-Remaining: 0signals 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 /
RetryableExceptionfailures; 4xx (except 429) doesn't count. New non-retryableCircuitOpenExceptionshort-circuits before the rate limiter and retries. Configure undercircuit_breaker.*. SchemaDriftExceptionreplaces silentnullreturns 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
RequestContextargument 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 becomesat()->as(), and the standalonerequest()/requestAs()methods collapse into onerequest()with an optional$responseClassargument.$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$classoptional.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_mappingsunique 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:installcommand: interactive installer that introspects a provider'scredentialDataClass()/metadataDataClass()via reflection, prompts for required fields (masking secret-looking names), validates with the provider's rules, runs the health check if the provider implementsHasHealthCheck, and upserts theIntegrationrow. Non-interactive callers can supply every value via repeatable--credential=key=value/--metadata=key=valueflags. Use--forceto 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 aftercomposer 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:endpointprefix form in the endpoint argument, matching howfake()registers responses. A prefix that conflicts with an explicitmethod:argument raisesInvalidArgumentExceptioninstead of silently mismatching.
1.7.0
RetryableException: throw to mark an error as retryable, with optionalretryAfterSecondsandmaxAttempts. Takes priority overCustomizesRetryand default status-code logic. Updated retry decision chain.resultDataparameter onlogOperation(): nullable JSON column for structured operation output, separate frommetadata.OperationStartedevent: dispatched when an operation is logged with statusprocessing.
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(), andupsertByExternalId()now return properly generic types (?Ticketinstead of?Model).- Testing fake: wildcard endpoint matching (
tickets/*.json), respecting path segment boundaries. - Testing fake: method-aware fakes (
GET:endpointvsPUT:endpoint). - Testing fake: integration-scoped fakes via
forIntegration()fluent API. - Assertion methods now support optional
methodandintegrationIdfilters.
1.5.0
- Automatic detection and honoring of
Retry-Afterheaders (capped by config, default 10 minutes). 429 falls back to a fixed 30s only whenRetry-Afteris absent. - Integration providers can customize retryability and delay decisions via
CustomizesRetry. - New
retry.retry_after_max_secondsconfig setting to cap honoredRetry-Afterduration (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
SyncResultreturn type. - Added per-provider queues.
- Added rate limit backoff.
- Improved health notifications.
1.0.0
Initial release.