pocketarc / laravel-integrations
Production-ready third-party integrations for Laravel. Credential management, API request logging, rate limiting, sync scheduling, OAuth2, and health monitoring.
Requires
- php: ^8.3
- ext-mbstring: *
- illuminate/support: ^11.0|^12.0|^13.0
- spatie/laravel-data: ^4.0
Requires (Dev)
- barryvdh/laravel-ide-helper: ^3.6
- captainhook/captainhook: ^5.28
- larastan/larastan: ^3
- laravel/pint: ^1.24
- orchestra/testbench: ^10.0|^11.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^12.0
This package is auto-updated.
Last update: 2026-03-29 13:24:32 UTC
README
A Laravel 11-13 package for production-ready third-party integrations.
Provides the connection layer between your app and external APIs.
- Credential management
- API request logging
- Rate limiting
- Retry logic
- Sync scheduling
- OAuth2
- Health monitoring
- Webhook handling
- ID mapping
Installation
composer require pocketarc/laravel-integrations
Publish the config and migrations:
php artisan vendor:publish --tag=integrations-config php artisan vendor:publish --tag=integrations-migrations php artisan migrate
Quick start
1. Create a provider
A provider defines how your app talks to an external service. At minimum, implement IntegrationProvider:
namespace App\Integrations; use Integrations\Contracts\IntegrationProvider; class ZendeskProvider implements IntegrationProvider { public function name(): string { return 'Zendesk'; } public function credentialRules(): array { return [ 'subdomain' => ['required', 'string'], 'api_token' => ['required', 'string'], 'email' => ['required', 'email'], ]; } public function metadataRules(): array { return [ 'locale' => ['sometimes', 'string'], ]; } public function credentialDataClass(): ?string { return ZendeskCredentials::class; } public function metadataDataClass(): ?string { return null; } }
2. Register it
In config/integrations.php:
'providers' => [ 'zendesk' => App\Integrations\ZendeskProvider::class, ],
Or programmatically via the facade:
use Integrations\Facades\Integrations; Integrations::register('zendesk', ZendeskProvider::class);
3. Create an integration
use Integrations\Models\Integration; $integration = Integration::create([ 'provider' => 'zendesk', 'name' => 'Production Zendesk', 'credentials' => [ 'subdomain' => 'acme', 'api_token' => 'abc123', 'email' => 'admin@acme.com', ], 'metadata' => ['locale' => 'en-US'], ]);
Credentials are encrypted at rest automatically. Metadata is stored as plain JSON.
4. Make API requests
Every API call goes through $integration->request(), which handles logging, caching, rate limiting, retries, and health tracking:
$result = $integration->request( endpoint: '/api/v2/tickets.json', method: 'GET', callback: fn () => Http::withHeaders([ 'Authorization' => 'Bearer '.$integration->credentialsArray()['api_token'], ])->get("https://{$subdomain}.zendesk.com/api/v2/tickets.json"), );
Table of contents
- Making requests
- Provider contracts
- Typed credentials and metadata
- OAuth2
- Webhooks
- Scheduled syncs
- Health monitoring
- ID mapping
- Operation logging
- Events
- Artisan commands
- Testing
- Multi-tenancy
- Configuration reference
Making requests
Integration::request() wraps any API call with the full parameter list:
$result = $integration->request( endpoint: '/api/v2/tickets.json', method: 'GET', callback: fn () => Http::get($url), relatedTo: $ticket, // optional - links this request to a model requestData: ['status' => 'open'], // optional - logged (auto-captured for HTTP responses) cacheFor: now()->addHour(), // optional - cache the response serveStale: true, // optional - return expired cache on error maxRetries: 3, // optional - retry on transient errors (default: 1, no retries) );
The endpoint and method are logical identifiers. They can be real HTTP paths or SDK operation names:
// SDK-style: endpoint is a logical name $result = $integration->request( endpoint: 'customers.create', method: 'POST', callback: fn () => $stripe->customers->create(['email' => $email]), requestData: ['email' => $email], );
What happens inside request()
- Counts actual requests in the last minute against the provider's configured limit. Throws
RateLimitExceededExceptionif exceeded. - If
cacheForis set, looks for a matching unexpired response (same integration + endpoint + method + request data hash). - Runs your closure, measuring duration with
hrtime(). - Normalizes the response: Handles Laravel HTTP responses, Guzzle PSR-7 responses,
JsonResponse, arrays, objects, and strings. Extracts status code and body automatically. - If the request fails and stale cache exists, returns the stale response instead of throwing.
- Saves an
IntegrationRequestrecord with full request/response data, timing, and error details. - Calls
recordSuccess()orrecordFailure()on the integration, updatingconsecutive_failuresandhealth_status. - Dispatches
RequestCompletedorRequestFailed.
Response caching
Pass cacheFor to cache successful responses. Subsequent identical requests (matched by endpoint + method + request data hash) return the cached response without executing the callback.
$result = $integration->request( endpoint: '/api/v2/tickets.json', method: 'GET', callback: fn () => Http::get($url), cacheFor: now()->addHour(), serveStale: true, // fall back to expired cache if the live request fails );
Cache hits and stale hits are tracked per-response via cache_hits and stale_hits counters on IntegrationRequest.
Retries
Set maxRetries to automatically retry on transient errors (429, 5xx, connection errors):
$result = $integration->request( endpoint: '/api/v2/tickets.json', method: 'GET', callback: fn () => Http::get($url), maxRetries: 3, );
Each retry is persisted as its own IntegrationRequest row with retry_of pointing to the first attempt. Every attempt counts toward rate limiting and is visible in logs.
Backoff strategy:
| Status | Backoff |
|---|---|
| 429 | Fixed 30-second delay |
| 5xx | Exponential (attempt x 2s) |
| Connection error | Linear (attempt x 1s) |
| 4xx (except 429) | Not retried, thrown immediately |
Standalone retry handler
The RetryHandler can also be used independently of Integration::request():
use Integrations\RetryHandler; $result = RetryHandler::execute( callback: fn () => Http::get($url)->throw(), maxRetries: 3, retryableStatusCodes: [429, 500, 502, 503, 504], onRetry: function (int $attempt, Throwable $e) { Log::warning("Retry attempt {$attempt}", ['error' => $e->getMessage()]); }, );
Provider contracts
Every provider must implement IntegrationProvider. Optional interfaces add capabilities:
| Interface | Purpose |
|---|---|
IntegrationProvider |
Required. Name, credential/metadata rules and Data classes. |
HasScheduledSync |
Scheduled sync support with rate limits. |
HandlesWebhooks |
Inbound webhook handling with signature verification. |
HasOAuth2 |
OAuth2 authorization flow with token refresh. |
HasHealthCheck |
Lightweight connection testing. |
IntegrationProvider (required)
use Integrations\Contracts\IntegrationProvider; interface IntegrationProvider { public function name(): string; public function credentialRules(): array; // Laravel validation rules public function metadataRules(): array; // Laravel validation rules public function credentialDataClass(): ?string; // Spatie Data class or null public function metadataDataClass(): ?string; // Spatie Data class or null }
HasScheduledSync
use Integrations\Contracts\HasScheduledSync; use Integrations\Models\Integration; interface HasScheduledSync { public function sync(Integration $integration): void; public function defaultSyncInterval(): int; // minutes public function defaultRateLimit(): ?int; // requests/minute, null = unlimited }
Example:
class ZendeskProvider implements IntegrationProvider, HasScheduledSync { // ... name(), credentialRules(), metadataRules() ... public function sync(Integration $integration): void { $tickets = $integration->request( endpoint: '/api/v2/tickets.json', method: 'GET', callback: fn () => Http::get("https://{$subdomain}.zendesk.com/api/v2/tickets.json"), ); foreach ($tickets['tickets'] as $ticket) { // Process each ticket... } $integration->markSynced(); } public function defaultSyncInterval(): int { return 5; // every 5 minutes } public function defaultRateLimit(): ?int { return 400; // Zendesk allows ~400 requests/minute } }
HandlesWebhooks
use Integrations\Contracts\HandlesWebhooks; use Illuminate\Http\Request; use Integrations\Models\Integration; interface HandlesWebhooks { public function handleWebhook(Integration $integration, Request $request): mixed; public function verifyWebhookSignature(Integration $integration, Request $request): bool; }
Example:
class StripeProvider implements IntegrationProvider, HandlesWebhooks { // ... name(), credentialRules(), metadataRules() ... public function handleWebhook(Integration $integration, Request $request): mixed { $event = $request->input('type'); return match ($event) { 'invoice.paid' => $this->handleInvoicePaid($integration, $request), 'customer.created' => $this->handleCustomerCreated($integration, $request), default => null, }; } public function verifyWebhookSignature(Integration $integration, Request $request): bool { $secret = $integration->credentialsArray()['webhook_secret']; return hash_equals( hash_hmac('sha256', $request->getContent(), $secret), $request->header('Stripe-Signature', ''), ); } }
HasOAuth2
use Integrations\Contracts\HasOAuth2; use Integrations\Models\Integration; interface HasOAuth2 { public function authorizationUrl(Integration $integration, string $redirectUri, string $state): string; public function exchangeCode(Integration $integration, string $code, string $redirectUri): array; public function refreshToken(Integration $integration): array; public function revokeToken(Integration $integration): void; public function refreshThreshold(): int; // seconds before expiry to trigger refresh }
The exchangeCode() and refreshToken() methods return arrays that get merged into the integration's encrypted credentials. The expected keys are:
[
'access_token' => '...',
'refresh_token' => '...',
'token_expires_at' => '2026-03-24T12:00:00Z',
]
HasHealthCheck
use Integrations\Contracts\HasHealthCheck; use Integrations\Models\Integration; interface HasHealthCheck { public function healthCheck(Integration $integration): bool; }
Example:
class ZendeskProvider implements IntegrationProvider, HasHealthCheck { public function healthCheck(Integration $integration): bool { try { $integration->request( endpoint: '/api/v2/users/me.json', method: 'GET', callback: fn () => Http::get("https://{$subdomain}.zendesk.com/api/v2/users/me.json"), ); return true; } catch (\Throwable) { return false; } } }
Typed credentials and metadata
By default, $integration->credentials returns a plain array. Providers can declare a Laravel Data class for typed access via credentialDataClass() and metadataDataClass():
use Spatie\LaravelData\Data; class ZendeskCredentials extends Data { public function __construct( public string $subdomain, public string $api_token, public string $email, ) {} }
class ZendeskProvider implements IntegrationProvider { // ... public function credentialDataClass(): ?string { return ZendeskCredentials::class; } public function metadataDataClass(): ?string { return null; // plain array } }
Now $integration->credentials returns a ZendeskCredentials instance:
$integration->credentials->subdomain; // 'acme' $integration->credentials->api_token; // 'abc123'
Use $integration->credentialsArray() when you need the raw array regardless of whether a Data class is configured.
OAuth2
OAuth2 authorization with automatic token refresh is built in.
Routes
The package registers these routes automatically:
| Route | Name | Purpose |
|---|---|---|
GET /integrations/{id}/oauth/authorize |
integrations.oauth.authorize |
Start the OAuth flow |
GET /integrations/oauth/callback |
integrations.oauth.callback |
Handle provider callback |
POST /integrations/{id}/oauth/revoke |
integrations.oauth.revoke |
Revoke authorization |
Starting the flow
Link to the authorize route from your UI:
<a href="{{ route('integrations.oauth.authorize', $integration) }}">Connect to Zendesk</a>
The package generates a state token, caches it, and redirects the user to the provider's consent page via your authorizationUrl() implementation. After the user authorizes, the provider redirects back to the callback route, which exchanges the code for tokens and stores them in the encrypted credentials column.
Automatic token refresh
$token = $integration->getAccessToken();
This checks token_expires_at against the provider's refreshThreshold(). If the token is about to expire, it calls refreshToken() first, updates credentials, and returns the fresh token.
You can also check and refresh explicitly:
if ($integration->tokenExpiresSoon()) { $integration->refreshTokenIfNeeded(); }
Webhooks
Webhook routes are registered automatically:
| Route | Name | Purpose |
|---|---|---|
GET|POST /integrations/{provider}/webhook |
integrations.webhook |
Generic provider webhook |
GET|POST /integrations/{provider}/{id}/webhook |
integrations.webhook.specific |
Integration-specific webhook |
When a webhook arrives:
- The provider is resolved from the URL
- Signature is verified via
verifyWebhookSignature() - A
WebhookReceivedevent is dispatched - Your
handleWebhook()implementation is called - The request and result are logged as an
IntegrationRequestandIntegrationLog
Webhook routes have no middleware by default (most providers can't handle CSRF or session auth). Add signature verification middleware via config if needed.
Point your external service's webhook URL at:
https://yourapp.com/integrations/zendesk/webhook
https://yourapp.com/integrations/zendesk/42/webhook # for a specific integration
Replaying webhooks
Since the full webhook payload is stored in IntegrationRequest, you can replay it:
php artisan integrations:replay-webhook {requestId}
This reconstructs the request from stored data and re-dispatches it through handleWebhook(). Useful when a handler had a bug that's since been fixed.
Scheduled syncs
Providers that implement HasScheduledSync get automated sync scheduling.
Setup
Add one line to your app's scheduler:
// bootstrap/app.php (Laravel 11+) Schedule::command('integrations:sync')->everyMinute();
The integrations:sync command finds all active integrations where next_sync_at has passed and dispatches a SyncIntegration job for each. Jobs use WithoutOverlapping to prevent concurrent syncs of the same integration.
Per-integration intervals
Each integration can have its own sync frequency:
$integration->update([ 'sync_interval_minutes' => 5, // sync every 5 minutes 'next_sync_at' => now(), // start immediately ]);
If sync_interval_minutes is null, the provider's defaultSyncInterval() is used. If neither is set, the integration is not scheduled for sync.
After a successful sync, markSynced() sets last_synced_at to now and computes the next next_sync_at.
Health-aware backoff
The sync scheduler respects health status. Degraded integrations sync at a reduced frequency, and failing integrations back off heavily:
| Health Status | Interval Multiplier | Example (5-min base) |
|---|---|---|
| Healthy | 1x | Every 5 minutes |
| Degraded | 2x (configurable) | Every 10 minutes |
| Failing | 10x (configurable) | Every 50 minutes |
Health monitoring
Integration health is tracked automatically based on request outcomes, using a circuit breaker pattern.
How it works
Each successful request resets consecutive_failures to 0 and sets health_status to healthy. Each failure increments consecutive_failures and updates last_error_at. After 5 consecutive failures (configurable), status transitions to degraded; after 20, to failing. Any subsequent success resets back to healthy.
Every health transition dispatches an IntegrationHealthChanged event with the previous and new status.
Health checks
Providers that implement HasHealthCheck can be probed without running a full sync:
php artisan integrations:test
Querying by health
Integration::where('health_status', 'failing')->get(); Integration::where('health_status', 'degraded')->get();
ID mapping
Track the relationship between external provider IDs and your internal models:
// Map an external ID to an internal model $integration->mapExternalId('ticket-4521', $ticket); // Resolve: external ID -> internal model $ticket = $integration->resolveMapping('ticket-4521', Ticket::class); // Reverse: internal model -> external ID $externalId = $integration->findExternalId($ticket);
Mappings are scoped to the integration, so the same external ID can map to different internal models across integrations. The unique constraint is on (integration_id, external_id, internal_type).
mapExternalId() uses updateOrCreate, so calling it again with the same external ID and type updates the mapping rather than creating a duplicate.
Operation logging
Log business-level operations (syncs, imports, webhooks) separately from individual API requests:
$log = $integration->logOperation( operation: 'sync', direction: 'inbound', status: 'success', summary: 'Synced 42 tickets from Zendesk', metadata: ['ticket_count' => 42, 'new' => 12, 'updated' => 30], durationMs: 3200, );
Hierarchical logging
Use parentId for per-record granularity under a parent operation:
$parentLog = $integration->logOperation( operation: 'sync', direction: 'inbound', status: 'success', summary: 'Full ticket sync', ); foreach ($tickets as $ticket) { $integration->logOperation( operation: 'sync', direction: 'inbound', status: 'success', externalId: $ticket['id'], summary: "Imported ticket {$ticket['id']}", parentId: $parentLog->id, ); }
Querying logs
$integration->logs()->successful()->get(); $integration->logs()->failed()->forOperation('sync')->get(); $integration->logs()->topLevel()->recent(48)->get(); // top-level logs from last 48 hours
Events
All events carry the relevant model(s) and use Laravel's standard Dispatchable and SerializesModels traits.
| Event | Payload | When |
|---|---|---|
IntegrationCreated |
$integration |
An integration is created |
IntegrationSynced |
$integration |
markSynced() is called |
IntegrationHealthChanged |
$integration, $previousStatus, $newStatus |
Health status transitions |
RequestCompleted |
$integration, $request |
An API request succeeds |
RequestFailed |
$integration, $request |
An API request fails |
OperationCompleted |
$integration, $log |
An operation is logged with status success |
OperationFailed |
$integration, $log |
An operation is logged with status failed |
WebhookReceived |
$integration, $provider |
A webhook arrives |
OAuthCompleted |
$integration |
OAuth2 authorization completes |
OAuthRevoked |
$integration |
OAuth2 authorization is revoked |
Listen for them with attribute-based listeners or in your EventServiceProvider:
use Integrations\Events\IntegrationHealthChanged; class NotifyOnHealthDegradation { public function handle(IntegrationHealthChanged $event): void { if ($event->newStatus->value !== 'healthy') { // Notify the team via Slack, email, etc. } } }
Artisan commands
| Command | Purpose |
|---|---|
integrations:sync |
Find overdue integrations, dispatch sync jobs |
integrations:list |
Show all integrations with health, last sync, request counts |
integrations:health |
Detailed health report (error rates, response times, top errors) |
integrations:test |
Run HasHealthCheck on all supporting integrations |
integrations:prune |
Clean up old request and log records |
integrations:replay-webhook {id} |
Re-dispatch a stored webhook payload |
integrations:list example output
┌──────────┬──────────┬─────────┬─────────────────────┬──────────┬───────────┐
│ Name │ Provider │ Health │ Last Synced │ Requests │ Error Rate│
├──────────┼──────────┼─────────┼─────────────────────┼──────────┼───────────┤
│ Prod ZD │ zendesk │ healthy │ 2026-03-22 10:15:00 │ 1,243 │ 0.8% │
│ GitHub │ github │ degraded│ 2026-03-22 10:10:00 │ 891 │ 12.3% │
└──────────┴──────────┴─────────┴─────────────────────┴──────────┴───────────┘
Pruning schedule and configure
Add to your scheduler:
Schedule::command('integrations:prune')->daily();
Configure retention in config/integrations.php:
'pruning' => [ 'requests_days' => 90, // delete IntegrationRequest records older than 90 days 'logs_days' => 365, // delete IntegrationLog records older than 1 year 'chunk_size' => 1000, // delete in chunks to avoid table locks ],
Testing
A testing fake follows the Http::fake() pattern, with no real API calls and no database writes.
use Integrations\Models\IntegrationRequest; // Activate the fake (optionally with canned responses) IntegrationRequest::fake([ '/api/v2/tickets.json' => ['tickets' => [['id' => 1, 'subject' => 'Test']]], 'customers.create' => fn () => ['id' => 'cus_123', 'email' => 'test@example.com'], ]); // ... run your code that calls $integration->request() ... // Assert IntegrationRequest::assertRequested('/api/v2/tickets.json'); IntegrationRequest::assertRequested('/api/v2/tickets.json', times: 2); IntegrationRequest::assertNotRequested('customers.delete'); IntegrationRequest::assertRequestedWith('customers.create', function (string $requestData) { return str_contains($requestData, 'test@example.com'); }); // Clean up IntegrationRequest::stopFaking();
When the fake is active, Integration::request() skips rate limiting, caching, health tracking, and database persistence entirely. It records requests in memory and returns your fake responses (or null for unmatched endpoints).
Multi-tenancy
The Integration model has optional polymorphic owner_type/owner_id columns for multi-tenant setups:
// Assign ownership $integration = Integration::create([ 'provider' => 'zendesk', 'name' => 'Acme Zendesk', 'credentials' => [...], 'owner_type' => Team::class, 'owner_id' => $team->id, ]); // Query by owner Integration::ownedBy($team)->get(); // Access the owner $integration->owner; // returns the Team model
If you don't need multi-tenancy, leave these columns null.
Configuration reference
Full config/integrations.php
return [ // Prefix for all database tables: {prefix}s, {prefix}_requests, {prefix}_logs, {prefix}_mappings. 'table_prefix' => 'integration', // Prefix for all cache keys used by this package (e.g. OAuth state tokens). 'cache_prefix' => 'integrations', 'webhook' => [ 'prefix' => 'integrations', // URL prefix: POST /{prefix}/{provider}/webhook 'middleware' => [], // no CSRF by default; webhooks can't carry tokens ], 'oauth' => [ 'route_prefix' => 'integrations', // URL prefix for OAuth routes 'middleware' => ['web'], // authorize + revoke routes 'callback_middleware' => ['web'], // callback route (redirect from provider) 'success_redirect' => '/integrations', // where to redirect after OAuth completes 'state_ttl' => 600, // state token validity in seconds (10 min) ], 'sync' => [ 'queue' => 'default', // queue for SyncIntegration jobs 'lock_ttl' => 600, // WithoutOverlapping lock TTL in seconds ], 'rate_limiting' => [ 'enabled' => true, // check request counts before each call ], 'request_logging' => [ 'enabled' => true, // persist every request to integration_requests 'cache_enabled' => true, // enable response caching via cacheFor parameter ], 'health' => [ 'degraded_after' => 5, // consecutive failures -> degraded 'failing_after' => 20, // consecutive failures -> failing 'degraded_backoff' => 2, // sync interval multiplier when degraded 'failing_backoff' => 10, // sync interval multiplier when failing ], 'pruning' => [ 'requests_days' => 90, // retention for integration_requests 'logs_days' => 365, // retention for integration_logs 'chunk_size' => 1000, // rows per delete batch ], // Provider class registration 'providers' => [ // 'zendesk' => App\Integrations\ZendeskProvider::class, ], ];
License
MIT. See LICENSE for details.