Skip to content

Changelog ​

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

6.5.1 ​

  • Fix: saving an integration no longer rewrites its encrypted credentials column when the credentials haven't changed. Encrypting the same credentials twice gives two different ciphertexts, and Eloquent compared the ciphertexts. If the provider's credentialDataClass() returns a Data class, reading $integration->credentials made the column count as changed, so every save after that read wrote it again. The package calls recordSuccess() to update the integration's health after every successful request, so the column was written on every request. The database locks the row for each of these writes, so they made lock wait timeouts more likely. The credential cast now decrypts both values and compares the decoded JSON, so assigning the same credentials is no longer a change either. Credentials that only a key in APP_PREVIOUS_KEYS can decrypt count as changed, so the first such save writes them again, encrypted with the current APP_KEY. Saves after that don't write the column.
  • Fix: saving an integration no longer rewrites its metadata column when the metadata hasn't changed. Some databases, such as MySQL, change the whitespace and key order of the JSON stored in a JSON column, so the JSON read back can differ from the JSON written. If the provider's metadataDataClass() returns a Data class, reading $integration->metadata made the column count as changed, and every save after that read wrote it again. The metadata cast now compares the decoded JSON and ignores whitespace and object key order.

6.5.0 ​

  • Change: IntegrationTestCase sets laravel-data's validation_strategy to Always. With laravel-data's default, OnlyRequests, Data::from() validates only when it hydrates a Data class from an HTTP request, so response Data classes were never validated in an adapter's tests. A consumer that sets Always validates every response, and a response that passed the adapter's tests could fail in production with a SchemaDriftException. For example, Zendesk sends an empty subject for a ticket created from an email with no subject. That ticket failed ZendeskTicketData validation, and the Zendesk incremental sync stopped at the page that contained it. A test that extends IntegrationTestCase now fails if its fixture has an empty string or an empty array in a non-nullable property of a Data class. Only tests use IntegrationTestCase, so this setting has no effect on production code. See the upgrade guide.
  • Fix: integrations:test counted an integration twice in its "Tested" total when the provider's health check threw an exception. Now the command counts that integration once.
  • Docs: the IntegrationTestCase section said that the class activates the fake in setUp() and provides $this->integration, but it does neither. The CreatesIntegration example called createIntegration() without the provider class, which is a required argument. Both sections are corrected.

6.4.0 ​

integrations:recover-webhooks now dispatches a new job for a webhook stuck in pending, and it can retry a failed webhook.

This release adds an attempts column and a (status, updated_at) index to the webhooks table. Fresh installs get both from the baseline migration. For an existing deployment, add the downstream migration from the upgrade guide.

  • Fix: integrations:recover-webhooks dispatches a new job for a webhook that has been pending for longer than webhook.pending_timeout, 1 hour by default. A webhook whose job was lost from the queue store stayed pending with no job to process it, and the command, which looked only for webhooks stuck in processing, never found it. If the first job is still queued, only one of the two jobs can claim the webhook, so the handler runs once. The command dispatches at most one extra job per webhook per timeout, even when two runs overlap.
  • New: failed webhooks can be retried. integration_webhooks.attempts counts the times a job has claimed the webhook. With webhook.max_attempts above its default of 1, the command moves a failed webhook back to pending and dispatches a job once the webhook.retry_backoff wait for its last attempt has elapsed. It stops when the webhook reaches max_attempts. Retries are off by default, because a retry runs the handler again from the start. Before this release, a failed webhook stayed failed until someone ran integrations:replay-webhook on it: a queue retry of ProcessWebhook does nothing, because the job claims only a pending webhook. A webhook that failed before this release has 0 attempts, and the command never retries it. See recovering stranded webhooks.
  • Fix: ProcessWebhook claims a webhook before it checks the webhook's integration and provider. It used to check them first, so a late duplicate job for a webhook that another job had already processed could mark that webhook failed when its provider no longer implemented HandlesWebhooks. The claim increments attempts, so a failure at either check is now counted as an attempt, and a webhook that keeps failing there stops being retried at webhook.max_attempts.
  • Fix: a job that runs past webhook.processing_timeout no longer overwrites the result of the job dispatched to replace it. integrations:recover-webhooks resets such a webhook to pending and dispatches a new job, but the first job could still be running, and when it finished, it wrote its result over whatever the new job had recorded. After markProcessing() claims a webhook, markProcessed() and markFailed() on the same model instance update it only while it is still processing at the claimed attempt. Without a successful markProcessing() on the same instance, they update the webhook unconditionally, as before. markProcessing() now claims a webhook only if attempts still holds the value the instance loaded, so an instance loaded before another job's claim cannot claim the same attempt after a reset.
  • The recommended schedule for the command is now every five minutes instead of hourly, because a failed webhook is retried only when the command runs.
  • Fix: the retry handler reads Retry-After from exceptions whose getResponse() method returns a PSR-7 response, such as an SDK's own exception or a Guzzle 8 ResponseException that isn't a BadResponseException. It checks the thrown exception first, then its previous exceptions in order (getPrevious()), and reads the header from the first response it finds. In 6.3.0, it read the header only from Guzzle's BadResponseException, so for any other exception it used its default delay. See SDK exception support.
  • Fix: the retry handler reads Retry-After from Illuminate\Http\Client\RequestException. Before this release, it did not read the header from that exception, so it used its default delay: 30 seconds after a 429, or 2 seconds times the attempt number after a 5xx.
  • Fix: a request whose upstream call succeeded no longer fails when json_encode can't encode the response for storage. json_encode throws on values such as INF, NAN, and invalid UTF-8. A callback can return one of these. For redaction, the package decodes the body and encodes it again, and PHP decodes a number too large for a float, such as 1e999, to INF. Previously, when a callback's return value couldn't be encoded, the request was recorded as a failure and counted against the circuit breaker and the integration's health. When a redacted body couldn't be encoded again, the caller got a JsonException and no request row was stored. Now the caller gets the callback's return value unchanged, and the package stores an [UNENCODABLE <exception message>] marker in place of the whole body, so redacted fields still never reach the database. The response isn't cached, even with cacheFor. When a request body couldn't be encoded again after redaction, the caller got a JsonException before the upstream call was made. Now the package makes the call and stores the marker as the request data. It doesn't store the response in the cache or look for a cached one. The cache key is a hash of the request data. If json_encode throws the same error for two different request bodies, both are replaced by the same marker, and a caller could otherwise be served the cached response to a different request. See data redaction.
  • Fix: a request whose body contains a field from the provider's sensitiveRequestFields() is no longer cached or served from the cache. Before the package calculates the cache key's hash, it replaces the value of each of these fields with [REDACTED]. Two requests that differed only in a redacted field, such as a password, had the same cache key. The second caller got the first caller's cached response. cacheFor and serveStale now have no effect on these requests. See requests that are never cached.
  • Fix: when the package redacts a body, it replaces only the configured fields that are present. It used to add a [REDACTED] field for each configured path that the body did not contain. It also decoded and encoded every JSON body again, so a stored body could differ from the original even when nothing was redacted. Now, if a body contains none of the configured fields, the package stores it unchanged, byte for byte. See data redaction.
  • Fix: a request with a binary body can now be served from the cache. The package stores a binary request body as a [BINARY <length> bytes sha256=<hash>] marker. The stored hash was taken from the marker, but the lookup hash was taken from the raw bytes, so the two never matched. The package now calculates both hashes from the raw bytes. A text body that is identical to a binary body's marker no longer has the same cache key as the binary request. See how response caching works.
  • Fix: a response whose body contains a field from the provider's sensitiveResponseFields() is no longer cached. The stored copy of the response has [REDACTED] in place of each secret, such as a token, and a caller served from the cache got that copy. See requests that are never cached.
  • Fix: two request bodies that differ only after their first 65530 bytes now have different cache keys. The package stores only the first 65530 bytes of a request body, and it took the cache key's hash from that truncated copy. The second caller got the first caller's cached response. The package now hashes the whole body. The cache key's hash also has a new format, so the package doesn't serve cache entries stored before the upgrade. On the next matching request, the package calls the upstream again and caches the new response. See how response caching works.

6.3.0 ​

  • Fix: authenticatedUser(cacheFor: ...) no longer throws a TypeError on a cache hit when cache.serializable_classes is false, a setting under which cached objects can't be unserialized. The identity is now cached as JSON under a new key. During a deploy, workers still on an older release read only the old key, so they never read an entry in the new format. A malformed cached entry counts as a cache miss, and the identity is fetched again and written over it. On a cache hit, authenticatedUser() now builds a plain AuthenticatedUser from the cached fields, so any property that a provider's subclass adds is lost. Each object in raw is returned as the array or scalar that json_decode() produces from its JSON. On a cache miss, authenticatedUser() still returns the provider's own object.
  • Fix: now that the identity is cached as JSON, authenticatedUser(cacheFor: ...) throws UncacheableAuthenticatedUser and caches nothing if the provider returns an identity that isn't JSON-encodable or whose raw has an integer top-level key. PHP converts a numeric string key such as "123" to an integer when it decodes JSON, so if raw is decoded from an upstream object with a numeric top-level key, the identity can't be cached. A call without cacheFor is unaffected. See implementing it on a provider.
  • Fix: the cached identity is now cleared when the OAuth callback or revoke route runs. Previously, the cached entry held the old account until it expired, and both authenticatedUser(cacheFor: ...) and integrations:health read from it. With the one-day TTL from the caching example, that meant up to a day.
  • Fix: the package works with Guzzle 8, which Composer can install alongside Laravel 13.33.0. In Guzzle 8, RequestException has no getResponse() method. If an exception chain contained a RequestException without a response, the package threw an Error when it called that method to read the status code or the Retry-After header. The package now reads the response from BadResponseException, which Guzzle throws for 4xx and 5xx responses in both Guzzle 7 and 8. See SDK exception support.
  • New: Integration::forgetAuthenticatedUser() clears the cached identity. Call it after changing credentials outside the OAuth routes. See credential changes.

6.2.0 ​

A sync run always reaches a terminal state, and the schedule always moves afterwards. Everything below closes a case where one or the other did not hold.

This release adds consecutive_sync_failures and sync_stale_alerted_at to the integrations table. Fresh installs get both from the baseline migration. For an existing deployment, add the downstream migration from the upgrade guide.

  • Fix: sync items abandoned in flight no longer block every future run. A queue worker that dies mid-batch leaves its rows at pending/processing with no job behind them, and SyncIntegration's preflight check finds them on every later run. No command cleared such rows: cleanupOrphanedItems() matched only a null batch_id, integrations:skip-sync-item accepts only failed, and integrations:advance-cursor does nothing while items are non-terminal. Rows in flight past sync.item_reclaim_after are now marked failed and their run re-finalised, so the run log closes, the cursor stays put, and the next run re-enumerates the work. The threshold cannot be sync.job_timeout, because a rate-limited item is released back onto the queue and sits in flight for hours by design. It is never applied below item_retry_window plus an hour. See abandoned items.
  • Fix: abandoned rows are marked failed rather than deleted, including when the batch was never dispatched. Deleting every row of a run strands its log at processing, because FinaliseSyncRun stops early on an empty item set.
  • New: --reclaim-stale on integrations:advance-cursor marks abandoned rows failed and reconciles their runs at once, rather than waiting out sync.item_reclaim_after.
  • Fix: next_sync_at advances on every finalised run, and last_synced_at only on a clean one. markSynced() ran only when a run had no failures, so a single permanently-failing item froze both timestamps. dueForSync() matches next_sync_at <= now(), so the integration stayed due on every scheduler tick and re-ran the same failing sync against the provider's API. A new consecutive_sync_failures column sets a doubling multiplier on the interval, capped by sync.failure_backoff_max_multiplier. The first failure keeps the plain interval, so an isolated bad run costs nothing. Separating the two timestamps also makes staleness detectable. See when runs keep failing.
  • Fix: the per-run bookkeeping in FinaliseSyncRun happens exactly once. The job has four dispatch sites by design and retries ten times, and markSynced() sat outside the once-only guard, so each re-finalisation fired IntegrationSynced and moved the sync timestamps again.
  • New: sync staleness as a signal in its own right. consecutive_failures counts API-boundary faults and resets on any success, so a sync that fetches successfully and then fails while processing resets it every pass, and the integration counts as healthy for as long as it stays stuck. isSyncStale() and syncStaleness() derive the signal, integrations:health and integrations:list flag it, and integrations:sync emits SyncBecameStale once per episode, with SyncStalenessRecovered when a clean sync clears it. Staleness is deliberately not a HealthStatus: recordSuccess() sets that to Healthy on every successful request, so a degradation written there would be overwritten within seconds.
  • Fix: an incident closes only once every signal is clear, rather than on health alone, and integrations:prune no longer auto-closes one for an integration that is healthy but sync-stale. Both previously closed an incident whose condition was still current. Incidents record sync as a source alongside health and circuit.
  • New: SyncItemStuck carries the single record a sync cannot advance past, rather than the run that failed on it. The row's attempts column cannot record the streak, because each run inserts fresh rows and the counter never accumulates. The streak is counted across rows that share an external ID, bounded to the IDs that failed in the run just finalised, and reset by any later success. Threshold: sync.stuck_item_after_runs.
  • Fix: a provider that emits the same external ID twice in one run no longer produces two jobs for it. A provider paging on an inclusive cursor boundary emits the record on that boundary twice. Both copies previously ran at the same time on different workers and wrote the same record. SyncSession::dispatch() keeps the first copy and records the number dropped on the run's log. See duplicate items.
  • Fix: the "previous batch still in flight" log line is a warning rather than info, and includes how long the batch has been in flight. It was logged at info level and worded the same as the line for an ordinary slow run.

6.1.0 ​

  • Fix: integrations:find-orphans selects only the id and created-at columns instead of whole rows. It read whole rows before, so on a model that stores payloads inline the command loaded every byte of every 1000-row chunk. One consumer exhausted a 512MB memory limit on a table of file attachments before the command printed anything.
  • Fix: three more commands select only the columns they use. integrations:recover-webhooks loaded every stale webhook's payload and headers to reset a status by id. That command runs when the queue is backed up, so its scan covers the largest payloads. integrations:advance-cursor loaded each sync log's metadata, result_data and error to dispatch a job by id. integrations:list-failed-items loaded checkpoint_value, which its table never shows.
  • New: --limit on integrations:list-failed-items (default 50) and integrations:advance-cursor (no default). Both read every matching row before. The listing commands print a notice when they stop at the limit, so a truncated list is not mistaken for the whole set. integrations:advance-cursor has no default limit, because a silent cap there would leave sync runs unreconciled.

6.0.0 ​

One external ID maps to exactly one local row per integration and model type, and the package now enforces that rather than assuming it. See the upgrade guide for the migration.

  • Breaking: mapExternalId() throws MappingAlreadyClaimed when a different model of the same type already holds the external ID on that integration, instead of silently re-pointing the mapping at the new one. The silent version made a lost race unobservable: two workers upserting the same external ID each inserted a local row, the second took the mapping, and the first was left intact in every column but unaddressable upstream. The upgrade guide has what that cost one consumer. Calling it twice with the same model is still a no-op.
  • New: remapExternalId() moves a mapping deliberately, which is what mapExternalId() used to do by accident. The displaced model keeps its row and loses its external ID; reconciling it is the caller's job.
  • Fix: upsertByExternalId() is serialised per (integration, model type, external ID) with a cache lock, and converges on the winner's row if a claim collides anyway. The lock only serialises across processes on a shared cache driver. TTL and wait are configurable under mappings.
  • New: integrations:find-orphans lists rows of a mapped model that have no external ID, for finding rows that lost their mapping before 6.0. It compares keys in PHP rather than joining, so it works regardless of the collation on internal_id.
  • New: integrations.mappings.collation pins the mapping table's string columns on MySQL and MariaDB. internal_id is a VARCHAR holding keys of every type, so comparing it against your own primary keys fails with Illegal mix of collations when your tables use a different collation. Null (the default) inherits the connection default. A second migration applies it to existing installs.
  • Breaking: IntegrationCreated now implements ShouldDispatchAfterCommit. Eloquent's created hook fires on insert, not on commit, so a listener running inside an open transaction could act on an integration that never persisted. upsertByExternalId() rolls back exactly that insert whenever it loses the mapping claim. A listener that has to run before the commit, to write into the same transaction, needs a model hook instead.
  • The ID-mapping methods moved from Integration onto an Integrations\Concerns\MapsExternalIds trait, and key stringification onto Integrations\Support\ModelKey. The public API on Integration is unchanged.

5.5.0 ​

  • New: two ways to stop response bodies dominating integration_requests, which in a busy installation is reliably the largest table the package owns. Request bodies were already cut at just under 64KB; response bodies were stored whole, so one verbose endpoint could account for most of the table. logging.max_response_bytes caps a stored body and records the size it was cut from, and the new LimitsRequestLogging contract lets a provider name endpoint patterns whose bodies are not stored at all, for payloads that are enormous or already persisted better elsewhere. Both leave the request row itself intact, so health, failure rates and the stats commands are unchanged. Defaults keep current behaviour: no cap, nothing opted out.
  • Bodies the package reads back are exempt from both the cap and the contract. A response being cached is the cache's payload, and a response to an idempotent write backs IdempotencyConflict recovery, so truncating or dropping either would turn a logging setting into a correctness bug.
  • Endpoint patterns now come from one implementation, Support\EndpointPattern, shared by the testing fake's expectations and the new contract. A pattern written against a fake keeps its meaning in provider configuration.

5.4.0 ​

  • Fix: FinaliseSyncRun now dispatches onto the same queue as the run's ProcessSyncItem jobs instead of the default queue. Reconciliation is the bookkeeping the cursor depends on, and on the default queue it competed with whatever else a consumer runs there; a backed-up default queue could leave runs unfinalised indefinitely while their items succeeded. On the item queue, finalisation drains with the batch it belongs to and honours per-provider sync.queues routing. The new FinaliseSyncRun::dispatchFor() is the single dispatch path used by the batch's finally callback, the retry catch-up in ProcessSyncItem, integrations:skip-sync-item, and integrations:advance-cursor.

5.3.1 ​

  • Fix: failureSummary() no longer trips PHP 8.4+'s "Using null as an array offset is deprecated" warning. FailureReporter's per-class and per-status breakdowns grouped on a nullable column and fed the result to pluck('count', <column>), so a failed request with a null failure_class (a row written before that column existed) or a null response_code (a connection error or timeout, which carries no HTTP status) built a null array key. They now iterate the grouped rows, folding a null failure_class into unknown and a null response_code into other as before. The call was silent on PHP 8.2/8.3; on 8.4/8.5 it fired on routine summary calls, and a consumer that promotes deprecations to exceptions saw the call abort rather than warn. The test suite now sets failOnDeprecation in phpunit.xml, so the PHP 8.4/8.5 CI matrix catches this class of regression.

5.3.0 ​

A failure-observability layer on top of the existing resilience machinery: a way to alert on terminal failures only, a failure-summary API, a per-incident anomaly signal, and a durable incident history. The schema changes (integration_requests.failure_class, integration_logs.attempt / max_attempts, integrations.anomaly_alerted_at, and the new integration_incidents table) land in the canonical migration, so fresh installs get them on first migrate. For an existing deployment, add the downstream migration from the upgrade guide.

  • New: terminal-vs-transient failure semantics. A listener that logs failed inside a sync item re-fires OperationFailed on every retry, even when the item later succeeds — the bulk of the alert noise, and not fixable in the consumer because the retry state lives in ProcessSyncItem. The job now exposes it: a SyncAttemptContext (plain final readonly) set around the per-item event and readable via Integration::currentSyncAttempt(), mirroring the existing currentContext() escape hatch. logOperation() stamps the attempt onto the new integration_logs.attempt / max_attempts columns and onto OperationFailed (a nullable third constructor argument — existing listeners are unaffected). Alert on terminal failures only by moving the forward to SyncItemFailed, which still fires exactly once on exhaustion; SyncAttemptContext::isLikelyFinalAttempt() is a best-effort filter for operation-granularity OperationFailed hooks, never a substitute for it.
  • New: a failure-summary API. Integration::failureSummary(CarbonInterface $since) returns a FailureSummary (plain final readonly) with per-operation counts, distinct-item counts, failure rate, last error, and per-status / per-FailureClass breakdowns, computed from integration_requests and integration_logs. The integrations:health and integrations:stats commands now render this summary, so the CLI and consumers report the same numbers. New since() request/log scopes back it.
  • New: integration_requests.failure_class is now persisted (set at execution time from the same FailureClassifier verdict the breaker and health tracking use) so a per-class breakdown is queryable without re-classifying. New withFailureClass() scope and a (integration_id, failure_class, created_at) index.
  • New: an anomaly signal for alerting. integrations:evaluate-failures measures each active integration's failure rate over a rolling window and dispatches ElevatedFailureRate once per incident — not one per failure — plus FailureRateRecovered when it clears. The open/closed state is a durable column (integrations.anomaly_alerted_at), so a cache flush can't drop a pending recovery. Schedule the command yourself; thresholds live in the new observability config block, and where alerts go stays in the consumer. CircuitBreaker::inspect() also gains a failure_rate field exposing the breaker's live window rate.
  • New: a durable incident history. The package records an integration_incidents audit row from its own IntegrationHealthChanged / CircuitOpened / CircuitClosed / IntegrationDisabled events — one open incident per integration into which both health and circuit signals fold (tracking peak severity), closed on recovery. Unlike the cache-only circuit state, this survives a cache:clear, so "incidents since T" is answerable. Read it with the incidents() relation and the current_incident / has_open_incident accessors; integrations:prune sweeps closed incidents (pruning.incidents_days, default 365) and auto-closes stale-open ones for healthy integrations. Toggle with observability.incidents_enabled.
  • New: documented IntegrationLog::STATUS_* constants (success, failed, processing, partial, deferred) for the status field. The column stays a free string with the same event-dispatch behaviour; the constants are the documented vocabulary, nothing more.

5.2.0 ​

  • New: provider-scoped passthrough on the testing fake. IntegrationRequest::fake([...])->passthrough('openrouter') lets the named provider's requests fall through to the real request executor instead of being served from the fake -- for when that provider is faked at a layer underneath Integration::request() (e.g. an AI call routed through the breaker and retries but stubbed at the SDK). Other providers stay faked. Opt-in and idempotent. Passthrough requests run for real and aren't recorded by default, so they don't appear in assertRequested(); add recordPassthrough() to log them for the assertions anyway. Unmatched requests for non-passthrough providers still return null.

5.1.0 ​

  • New: the DeclaresRateLimit provider contract carries defaultRateLimit() on its own, so a request-only provider can ship an in-code rate budget without implementing HasScheduledSync. The method moved up from HasScheduledSync, which now extends DeclaresRateLimit, so existing sync providers satisfy the new contract unchanged. Integration::effectiveRateLimit() reads any DeclaresRateLimit provider, and a runtime override still takes precedence over the declared default. make:integration-provider gains a --rate-limit flag to scaffold a request-only provider's limit.
  • New: the IdentifiesAuthenticatedUser provider contract resolves which account an integration's credentials authenticate as, the principal behind the token, mapped to a provider-agnostic AuthenticatedUser. Read it through Integration::authenticatedUser(), which makes the upstream "who am I" call through the request executor (so the breaker, rate limiter, and logging apply), caches the result with an optional cacheFor and refresh, and throws UnsupportedByProvider when the provider doesn't implement the contract (pre-check with supportsAuthenticatedUser()). integrations:health shows the resolved identity for providers that support it.

5.0.0 ​

The circuit breaker is now an availability detector driven by a single failure classifier, and it can be controlled at runtime without a redeploy. See the upgrade guide for the migration.

  • Breaking: one FailureClassifier decides what a failure means, and the same verdict feeds both the breaker and health tracking. Only upstream faults (5xx except 501, connection errors, timeouts) count. HTTP 429 and other 4xx client errors no longer trip the breaker or degrade health: a throttle is the rate limiter's concern, and a malformed request from one caller can't pull an integration offline for everyone sharing it.
  • Breaking: the breaker now defaults to a rate strategy (failure percentage over a window) rather than a consecutive count. The config block gains strategy, time_window, failure_rate_threshold, and minimum_requests. Set strategy to 'count' for the previous consecutive-failure behaviour, which is also the volume-independent choice for low-traffic integrations.
  • Breaking: Integration::recordFailure() now takes a FailureClass argument.
  • New: the ClassifiesFailures provider contract maps an SDK's exceptions to a failure class. Core also duck-types the common SDK status accessors (getStatusCode(), getHttpStatus(), getHttpStatusCode(), getCode() when it's a valid HTTP status, a wrapped PSR-7 response), so most SDKs classify correctly without it.
  • New: runtime overrides that force a circuit open, closed, or disabled, and override a rate limit, with optional expiry, via model helpers (forceCircuitOpen(), overrideRateLimit(), …) or the integrations:circuit and integrations:rate-limit commands. Backed by new columns on the integrations table, so they survive a cache:clear. Toggle globally with circuit_breaker.overrides_enabled / rate_limiting.overrides_enabled.
  • New: CircuitOpened and CircuitClosed events fire on every transition (automatic or forced) with a reason, and a publishable SendCircuitNotification listener turns them into notifications. integrations:health and integrations:list now show breaker state.

4.2.0 ​

  • IdempotencyConflict now carries $e->priorState (an IdempotencyPriorState case: NoRow, EmptyBody, Unparseable, or Recovered) and $e->priorRowId alongside the existing $e->priorResponse. Catch blocks can now distinguish "no prior request on file" from "row exists but empty body" from "row exists but corrupt JSON", three failure modes that 4.1 collapsed into "priorResponse is null". Backward compatible: the new constructor arguments are added after $priorResponse with safe defaults, so existing positional callers keep working unchanged.
  • New Integration::getIdempotencyRecovery(string $key): IdempotencyRecovery method that returns the same (priorState, priorRowId, priorResponse) shape attached to the exception. getIdempotencyResponse() from 4.1 becomes a thin wrapper that returns just the decoded array for callers that don't care about the state distinction.

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.