golddevelopment / virtuagym
Typed PHP client for the Virtuagym API (v1 + v3) with Laravel support
Requires
- php: >=8.2
- cuyz/valinor: ^1.12
- guzzlehttp/guzzle: ^7.8
Requires (Dev)
- laravel/pint: ^1.18
- orchestra/testbench: ^9.0|^10.0
- pestphp/pest: ^3.0
- phpstan/phpstan: ^2.0
- vlucas/phpdotenv: ^5.6
Suggests
- illuminate/support: Required to use the bundled Laravel service provider and config
README
A typed PHP client for the Virtuagym API — v1 (api key + club secret) and v3 (OAuth client credentials) — with Laravel support.
- Typed models — every response is hydrated onto readonly model classes and validated by Valinor: responses that don't match the schema fail loudly instead of corrupting your data.
- Battle-tested against the live API — the behavior of every endpoint (pagination cursors, envelope quirks, type inconsistencies) was verified live; see API-FINDINGS for everything the docs don't tell you.
- Pagination handled — iterate lazily page by page with generators, or fetch everything with one call. Duplicate rows caused by the API's inclusive cursors are deduplicated for you.
- Laravel-ready — auto-discovered service provider,
config/virtuagym.php, both clients resolvable from the container. - Sibling packages: Node.js/TypeScript and Python.
Installation
composer require golddevelopment/virtuagym
Requires PHP 8.2+.
API v1 (api key + club secret)
You need three values, all found in Virtuagym under Business settings → Business Info → Advanced: your API key, the "Club Key" (club secret), and your club id.
use GoldDevelopment\VirtuaGym\V1\VirtuaGymClientV1; $client = new VirtuaGymClientV1( apiKey: getenv('VIRTUAGYM_API_KEY'), clubSecret: getenv('VIRTUAGYM_CLUB_SECRET'), clubId: 12345, );
Members & employees
// Lazily, page by page — each HTTP request only happens when you ask for the next page foreach ($client->members() as $page) { echo count($page)." members\n"; } // Or collect every page into a single array $members = $client->allMembers(); // Incremental sync (timestamp in ms) $changed = $client->allMembers(syncFrom: $lastSyncTimestamp); // Single member, optionally with membership instances embedded $member = $client->member(7302399, with: 'memberships'); // Create / update / upsert — mutations re-fetch and return the canonical record $created = $client->createMember(['firstname' => 'John', 'lastname' => 'Doe', 'email' => 'john@example.com']); $updated = $client->updateMember($created->member_id, ['gender' => 'f']); $upserted = $client->createOrUpdateMember(['external_id' => '1ABC234567', 'firstname' => 'John', 'lastname' => 'Doe']); // Employees work the same way $employees = $client->allEmployees(); $employee = $client->createEmployee(['firstname' => 'Jane', 'lastname' => 'Doe', 'add_priviliges' => ['coach']]); // Activate a user account for a member $result = $client->activateUser([ 'email' => 'user@example.com', 'password' => 'their-new-password', 'member_identifier' => ['type' => 'member_id', 'value' => 7302399], ]);
Memberships
$instances = $client->allMembershipInstances(memberId: 7302399); $definitions = $client->allMembershipDefinitions(status: 'active'); $contract = $client->createMembershipInstance([ 'membership_id' => 10215539, 'member_id' => 7302399, 'start_date' => '2026-08-01', 'payment_method' => 'direct_debit', 'salesperson_id' => 12345, ]);
Events & bookings
$events = $client->allEvents(timestampStart: $start, timestampEnd: $end); $participants = $client->allEventParticipants(eventId: $events[0]->event_id); $booking = $client->createEventParticipant(['event_id' => $events[0]->event_id, 'member_id' => 7302399]); $client->deleteEventParticipant($booking->event_participant_id); // cancel
Everything else
$client->clubTaxes(); // incl. the undocumented numeric club_tax_id you need for invoices $client->incomeCategories(); $client->allInvoices(); // page-based, 500/page $client->invoice($guid); // the live API only resolves invoices by guid $client->createInvoice([...]); $client->allVisits(memberId: 7302399); $client->createVisit(['member_id' => 7302399, 'action' => 'check_in']); $client->memberNotes(memberId: 7302399); // WARNING: the API caps this endpoint at the newest 500 notes $client->allMemberCredits(); // page-boundary duplicates are deduplicated for you $client->addMemberCredits(['member_id' => 7302399, 'service_type' => 'access', 'credit_amount' => 10, 'client_id' => $guid]); $client->assignWorkout(['plan_id' => 1, 'user_id' => $member->user_id, 'weeks' => 4, 'weekdays' => [1, 3], 'start_date' => '2026-08-01']); $client->bodymetrics(7302399); $client->updateBodymetric(['member_id' => 7302399, 'type' => 'weight', 'value' => 80]);
API v3 (OAuth)
The v3 API is a separate stack behind gateway.services.virtuagym.com, authenticated with OAuth client credentials (register via api@virtuagym.com). Tokens are requested and renewed automatically (club-bound, ~30 min lifetime). Which v3 resources you can reach depends on the scopes Virtuagym registered for your client — without the right scope the API answers a misleading 401 Token not valid..
use GoldDevelopment\VirtuaGym\V3\VirtuaGymClientV3; $client = new VirtuaGymClientV3( clientId: getenv('VIRTUAGYM_CLIENT_ID'), clientSecret: getenv('VIRTUAGYM_CLIENT_SECRET'), clubId: 12345, );
Leads
$leads = $client->allLeads(); $lead = $client->lead(751563); // At least one of email / phone / mobile is required $created = $client->createLead([ 'firstname' => 'Jane', 'lastname' => 'Doe', 'email' => 'jane.doe@example.com', 'status_id' => 1, // New — see Lead::STATUSES ]); $client->updateLead($created->lead_id, ['status_id' => 12]); // Closed won
Beware: the live API serializes every lead field as a string (ids, "0"/"1" flags, timestamps in seconds). Mutations return only the new lead's id, so the client re-fetches and returns the canonical record.
Schedule (appointments) — requires the schedule_public_api_club_<club_id> scope
$now = (int) (microtime(true) * 1000); $week = 7 * 24 * 3600 * 1000; $events = $client->allEvents(dateStart: $now, dateEnd: $now + $week, eventType: 'appointment'); $event = $client->event($events[0]->event_id); $bookings = $client->allEventBookings(dateStart: $now, dateEnd: $now + $week, memberId: 42); // Book a member (or original_member_id for superclubs, or a guest) $result = $client->createBooking($event->event_id, ['member_id' => 42]); // Inspect $result->bookings[0]->reason — see BookingAttempt::REASON_CODES. // NOTE: a member without the required credit type is booked UNPAID rather // than rejected — check payment_info if payment matters. $client->updateBooking($event->event_id, ['member_id' => 42, 'presence' => true]); $client->cancelBooking($event->event_id, memberId: 42, refund: false);
Note: occurrences of a recurring event share the same event_id and differ only in datetime_start — use event_id + datetime_start as the occurrence key.
Laravel
The service provider is auto-discovered. Set the env variables (VIRTUAGYM_API_KEY, VIRTUAGYM_CLUB_SECRET, VIRTUAGYM_CLIENT_ID, VIRTUAGYM_CLIENT_SECRET, VIRTUAGYM_CLUB_ID) and resolve the clients from the container:
use GoldDevelopment\VirtuaGym\V1\VirtuaGymClientV1; use GoldDevelopment\VirtuaGym\V3\VirtuaGymClientV3; class SyncMembersJob { public function __construct(private readonly VirtuaGymClientV1 $virtuagym) {} public function handle(): void { foreach ($this->virtuagym->members() as $page) { // ... } } }
Publish the config if you need to customize it:
php artisan vendor:publish --tag=virtuagym-config
Error handling
use GoldDevelopment\VirtuaGym\Exceptions\VirtuaGymApiException; use GoldDevelopment\VirtuaGym\Exceptions\VirtuaGymV3ApiException; try { $client->member(999); } catch (VirtuaGymApiException $e) { // v1: in-band API errors (the API reports these with HTTP 200), // real-HTTP-status errors, and schema-validation failures. echo $e->statuscode.' '.$e->getMessage(); // e.g. 420 'Not found.' }
VirtuaGymApiException(v1) —statuscode,statusmessage, optionalerrorspayload.VirtuaGymV3ApiException(v3) — real HTTP status inhttpStatus, invalid field names infields. On a 401 the client refreshes the token and retries once before throwing.
Development
composer install composer test # unit tests (offline, mocked HTTP) composer lint # pint composer analyse # phpstan # Smoke tests against the live API (read-only): cp .env.example .env # then fill in your credentials composer test:smoke
On Windows, if smoke tests fail with cURL error 60, point PHP at a CA bundle, e.g. php -d curl.cainfo="C:/Program Files/Git/mingw64/etc/ssl/certs/ca-bundle.crt" vendor/pestphp/pest/bin/pest --group=smoke.