jeffersongoncalves/laravel-calendly
Integrate Calendly scheduling into Laravel with this lightweight package. Manage scheduling links, events, invitees, and webhooks through a clean, configurable API wrapper, making it easy to sync bookings and automate scheduling workflows in your PHP applications.
This is a thin REST client for Calendly's v2 API — it does not model Calendly resources as Eloquent models, and it does not give you a booking engine. Treat it as a typed-ish HTTP wrapper and expect to write your own service layer on top.
Minimum setup:
composer require <vendor>/laravel-calendly
php artisan vendor:publish --tag=calendly-config
Then set the token in .env:
CALENDLY_TOKEN=eyJ...
Generate a Personal Access Token from Calendly → Integrations → API & Webhooks. The token is scoped to a single Calendly user (the one who created it), not to an organization.
First call to make — always — is users/me:
$me = Calendly::users()->me();
$userUri = $me['resource']['uri']; // https://api.calendly.com/users/XXXX
$orgUri = $me['resource']['current_organization']; // https://api.calendly.com/organizations/YYYY
Cache both of those (they never change for a given token). Almost every other endpoint needs one of them as a required filter — event_types, scheduled_events, organization_memberships, and webhook subscription creation all take a user or organization URI, not an ID. If you skip this step you will spend an hour passing UUIDs and getting 400s.
First real use case: list event types for the token owner and read the scheduling_url off each one to embed a booking button.
Wrap it, don't sprinkle it. The client returns raw arrays. Put a single CalendlyService behind the container that resolves and caches userUri/orgUri, unwraps resource/collection, and maps to your own DTOs. Everything downstream then deals with EventType objects, not ['resource' => ['uri' => ..., 'scheduling_url' => ...]].
Scheduled sync, not live reads. Pull scheduled_events on a schedule (every 5–15 min) into a local table keyed by event URI, then page invitees per event. Use min_time/max_time on every call — omitting them forces Calendly to scan a wide window and is noticeably slower. Persist pagination.next_page_token between runs so a large backfill can resume instead of restarting.
$page = Calendly::scheduledEvents()->list([
'user' => $userUri,
'min_time' => now()->subDays(1)->toIso8601ZuluString(),
'max_time' => now()->addDays(60)->toIso8601ZuluString(),
'status' => 'active',
'count' => 100,
]);
while ($page['pagination']['next_page_token'] ?? null) {
$page = Calendly::scheduledEvents()->list([
'user' => $userUri,
'page_token' => $page['pagination']['next_page_token'],
// repeat all original filters
]);
}
Webhook receiver shape. Register the route outside web middleware or exclude it from CSRF (Laravel 11+: ->withoutMiddleware(VerifyCsrfToken::class) in bootstrap/app.php; older: $except). The controller should verify the signature, DB::transaction insert the raw payload into a webhook_events table with a unique index on the Calendly event URI + event type, return 200 immediately, and dispatch a queued job. Calendly retries on non-2xx and on timeouts, so the unique index is your idempotency guard — do not rely on ShouldBeUnique alone.
Booking flow. The API has no "create event" endpoint. Your flow is: list event_types → read scheduling_url → redirect the user there. Availability endpoints (available_times, user_busy_times) are for rendering slots in your own UI, but the actual booking still happens on Calendly's hosted page unless you're on a paid plan using Calendly's embedded widget. Do not build a custom booking form expecting this package to submit it.
Multi-tenant. Because the token is config-driven, the default binding is a singleton for one account. For multi-tenant, register the client as scoped() and resolve a fresh instance per tenant with config(['calendly.token' => $tenant->calendly_token]) inside the request/job scope — or construct the client directly with an explicit token. Never let a queued job pick up a token from ambient config that another job mutated.
Testing. Fake at the HTTP layer (Http::fake([...])) rather than mocking the client — you get request-shape assertions for free and it catches the URI-vs-UUID mistakes early.
Str::afterLast($uri, '/'). Mixed this up constantly.Calendly-Webhook-Signature: t=<ts>,v1=<hmac>, where the HMAC is SHA256 over "{$t}.{$rawBody}" using the signing_key you supplied at subscription time. Verify with hash_equals and reject timestamps older than ~5 minutes. Pass signing_key when creating the subscription or you cannot verify anything.organization plus scope (user or organization). A user-scoped subscription only fires for that user's events; org-scoped requires an org admin token. Getting a 403 here is almost always a scope/token mismatch, not a code bug.resource containing the cancellation. reason is a required field.count maxes at 100. There is no ->paginate() helper and no auto-follow.->retry(3, 100) on a fan-out sync will just re-trigger the limit. Cap concurrency on sync jobs to 1–2.start_time/end_time come back UTC with a Z. Invitee timezones are separate fields. Store UTC, format at the edge; do not Carbon::parse() without ->utc() if your app timezone is set.event_types needs an owner. Either user or organization, never neither. Same for scheduled_events.composer.json. The wrapper's method names and return shapes are the most likely thing to churn.php artisan config:cache in deploy will freeze the token — fine for single-tenant, fatal for the per-tenant pattern above. Use env() only inside the published config file, and resolve tenant tokens at runtime, not from config.valet share / ngrok / expose; Calendly will not POST to localhost. Create the subscription with the tunnel URL, and delete it afterwards — leftover subscriptions count against the org limit.PendingRequest/HTTP client, prefer a macro or a wrapping service over forking. If it doesn't, that's the first thing to ask for upstream — you will want to inject headers, logging, and retry policy.How can I help you explore Laravel packages today?