golddevelopment/virtuagym

Typed PHP client for the Virtuagym API (v1 + v3) with Laravel support

Maintainers

Package info

github.com/gold-development/virtuagym-php

pkg:composer/golddevelopment/virtuagym

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-08-12 13:38 UTC

This package is auto-updated.

Last update: 2026-08-12 14:01:39 UTC


README

CI Packagist

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, optional errors payload.
  • VirtuaGymV3ApiException (v3) — real HTTP status in httpStatus, invalid field names in fields. 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.

License

MIT