Changelog
All notable changes to this project are documented here. This project follows Semantic Versioning.
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; existing deployments add a downstream migration, which the upgrade guide gives in full.
- Fix: sync items abandoned in flight no longer block every future run. A queue worker that dies mid-batch leaves its rows at
pending/processingwith no job behind them, andSyncIntegration's preflight check finds them on every later run. No command cleared such rows:cleanupOrphanedItems()matched only a nullbatch_id,integrations:skip-sync-itemaccepts onlyfailed, andintegrations:advance-cursordoes nothing while items are non-terminal. Rows in flight pastsync.item_reclaim_afterare now markedfailedand their run re-finalised, so the run log closes, the cursor stays put, and the next run re-enumerates the work. The threshold cannot besync.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 belowitem_retry_windowplus an hour. See abandoned items. - Fix: abandoned rows are marked
failedrather than deleted, including when the batch was never dispatched. Deleting every row of a run strands its log atprocessing, becauseFinaliseSyncRunstops early on an empty item set. - New:
--reclaim-staleonintegrations:advance-cursormarks abandoned rowsfailedand reconciles their runs at once, rather than waiting outsync.item_reclaim_after. - Fix:
next_sync_atadvances on every finalised run, andlast_synced_atonly on a clean one.markSynced()ran only when a run had no failures, so a single permanently-failing item froze both timestamps.dueForSync()matchesnext_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 newconsecutive_sync_failurescolumn sets a doubling multiplier on the interval, capped bysync.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
FinaliseSyncRunhappens exactly once. The job has four dispatch sites by design and retries ten times, andmarkSynced()sat outside the once-only guard, so each re-finalisation firedIntegrationSyncedand moved the sync timestamps again. - New: sync staleness as a signal in its own right.
consecutive_failurescounts 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()andsyncStaleness()derive the signal,integrations:healthandintegrations:listflag it, andintegrations:syncemitsSyncBecameStaleonce per episode, withSyncStalenessRecoveredwhen a clean sync clears it. Staleness is deliberately not aHealthStatus:recordSuccess()sets that toHealthyon 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:pruneno longer auto-closes one for an integration that is healthy but sync-stale. Both previously closed an incident whose condition was still current. Incidents recordsyncas asourcealongsidehealthandcircuit. - New:
SyncItemStuckcarries the single record a sync cannot advance past, rather than the run that failed on it. The row'sattemptscolumn 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-orphansselects 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-webhooksloaded every stale webhook'spayloadandheadersto reset a status by id. That command runs when the queue is backed up, so its scan covers the largest payloads.integrations:advance-cursorloaded each sync log'smetadata,result_dataanderrorto dispatch a job by id.integrations:list-failed-itemsloadedcheckpoint_value, which its table never shows. - New:
--limitonintegrations:list-failed-items(default 50) andintegrations: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-cursorhas 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()throwsMappingAlreadyClaimedwhen 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 whatmapExternalId()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 undermappings. - New:
integrations:find-orphanslists 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 oninternal_id. - New:
integrations.mappings.collationpins the mapping table's string columns on MySQL and MariaDB.internal_idis a VARCHAR holding keys of every type, so comparing it against your own primary keys fails withIllegal mix of collationswhen your tables use a different collation. Null (the default) inherits the connection default. A second migration applies it to existing installs. - Breaking:
IntegrationCreatednow implementsShouldDispatchAfterCommit. Eloquent'screatedhook 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
Integrationonto anIntegrations\Concerns\MapsExternalIdstrait, and key stringification ontoIntegrations\Support\ModelKey. The public API onIntegrationis 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_bytescaps a stored body and records the size it was cut from, and the newLimitsRequestLoggingcontract 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
IdempotencyConflictrecovery, 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:
FinaliseSyncRunnow dispatches onto the same queue as the run'sProcessSyncItemjobs 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-providersync.queuesrouting. The newFinaliseSyncRun::dispatchFor()is the single dispatch path used by the batch'sfinallycallback, the retry catch-up inProcessSyncItem,integrations:skip-sync-item, andintegrations: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 topluck('count', <column>), so a failed request with a nullfailure_class(a row written before that column existed) or a nullresponse_code(a connection error or timeout, which carries no HTTP status) built a null array key. They now iterate the grouped rows, folding a nullfailure_classintounknownand a nullresponse_codeintootheras 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 setsfailOnDeprecationinphpunit.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. Existing deployments need a downstream migration to add those columns and the table.
- New: terminal-vs-transient failure semantics. A listener that logs
failedinside a sync item re-firesOperationFailedon 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 inProcessSyncItem. The job now exposes it: aSyncAttemptContext(plainfinal readonly) set around the per-item event and readable viaIntegration::currentSyncAttempt(), mirroring the existingcurrentContext()escape hatch.logOperation()stamps the attempt onto the newintegration_logs.attempt/max_attemptscolumns and ontoOperationFailed(a nullable third constructor argument — existing listeners are unaffected). Alert on terminal failures only by moving the forward toSyncItemFailed, which still fires exactly once on exhaustion;SyncAttemptContext::isLikelyFinalAttempt()is a best-effort filter for operation-granularityOperationFailedhooks, never a substitute for it. - New: a failure-summary API.
Integration::failureSummary(CarbonInterface $since)returns aFailureSummary(plainfinal readonly) with per-operation counts, distinct-item counts, failure rate, last error, and per-status / per-FailureClassbreakdowns, computed fromintegration_requestsandintegration_logs. Theintegrations:healthandintegrations:statscommands now render this summary, so the CLI and consumers report the same numbers. Newsince()request/log scopes back it. - New:
integration_requests.failure_classis now persisted (set at execution time from the sameFailureClassifierverdict the breaker and health tracking use) so a per-class breakdown is queryable without re-classifying. NewwithFailureClass()scope and a(integration_id, failure_class, created_at)index. - New: an anomaly signal for alerting.
integrations:evaluate-failuresmeasures each active integration's failure rate over a rolling window and dispatchesElevatedFailureRateonce per incident — not one per failure — plusFailureRateRecoveredwhen 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 newobservabilityconfig block, and where alerts go stays in the consumer.CircuitBreaker::inspect()also gains afailure_ratefield exposing the breaker's live window rate. - New: a durable incident history. The package records an
integration_incidentsaudit row from its ownIntegrationHealthChanged/CircuitOpened/CircuitClosed/IntegrationDisabledevents — 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 acache:clear, so "incidents since T" is answerable. Read it with theincidents()relation and thecurrent_incident/has_open_incidentaccessors;integrations:prunesweeps closed incidents (pruning.incidents_days, default 365) and auto-closes stale-open ones for healthy integrations. Toggle withobservability.incidents_enabled. - New: documented
IntegrationLog::STATUS_*constants (success,failed,processing,partial,deferred) for thestatusfield. 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 underneathIntegration::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 inassertRequested(); addrecordPassthrough()to log them for the assertions anyway. Unmatched requests for non-passthrough providers still returnnull.
5.1.0
- New: the
DeclaresRateLimitprovider contract carriesdefaultRateLimit()on its own, so a request-only provider can ship an in-code rate budget without implementingHasScheduledSync. The method moved up fromHasScheduledSync, which now extendsDeclaresRateLimit, so existing sync providers satisfy the new contract unchanged.Integration::effectiveRateLimit()reads anyDeclaresRateLimitprovider, and a runtime override still takes precedence over the declared default.make:integration-providergains a--rate-limitflag to scaffold a request-only provider's limit. - New: the
IdentifiesAuthenticatedUserprovider contract resolves which account an integration's credentials authenticate as, the principal behind the token, mapped to a provider-agnosticAuthenticatedUser. Read it throughIntegration::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 optionalcacheForandrefresh, and throwsUnsupportedByProviderwhen the provider doesn't implement the contract (pre-check withsupportsAuthenticatedUser()).integrations:healthshows 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
FailureClassifierdecides 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, andminimum_requests. Setstrategyto'count'for the previous consecutive-failure behaviour, which is also the volume-independent choice for low-traffic integrations. - Breaking:
Integration::recordFailure()now takes aFailureClassargument. - New: the
ClassifiesFailuresprovider 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 theintegrations:circuitandintegrations:rate-limitcommands. Backed by new columns on the integrations table, so they survive acache:clear. Toggle globally withcircuit_breaker.overrides_enabled/rate_limiting.overrides_enabled. - New:
CircuitOpenedandCircuitClosedevents fire on every transition (automatic or forced) with a reason, and a publishableSendCircuitNotificationlistener turns them into notifications.integrations:healthandintegrations:listnow show breaker state.
4.2.0
IdempotencyConflictnow carries$e->priorState(anIdempotencyPriorStatecase:NoRow,EmptyBody,Unparseable, orRecovered) and$e->priorRowIdalongside 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$priorResponsewith safe defaults, so existing positional callers keep working unchanged.- New
Integration::getIdempotencyRecovery(string $key): IdempotencyRecoverymethod 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
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.