clinically / laravel-halaxy
Laravel SDK for the Halaxy FHIR R4B healthcare API
Requires
- php: ^8.5
- illuminate/cache: ^13.0
- illuminate/contracts: ^13.0
- illuminate/http: ^13.0
- illuminate/support: ^13.0
- spatie/laravel-webhook-client: ^3.4
Requires (Dev)
- laravel/pint: ^1.18
- orchestra/testbench: ^11.0
- pestphp/pest: ^5.0
- pestphp/pest-plugin-laravel: ^5.0
- phpstan/phpstan: ^2.0
README
A Laravel SDK for the Halaxy FHIR R4B healthcare API.
Important
This is an independent, unofficial project. It is not affiliated with, endorsed by, or supported by Halaxy. Halaxy is a trademark of its respective owner.
Features
- Fluent resource API for every documented Halaxy endpoint
- FHIR query builder with pagination (
paginate(), lazyall()) - Multi-tenant support with per-tenant OAuth token caching
- AU/EU region switching
- Webhook handling via
spatie/laravel-webhook-clientwith typed events - Typed exceptions for authentication, validation, rate-limit, and server errors
Requirements
- PHP 8.5+
- Laravel 13+
Installation
composer require clinically/laravel-halaxy
Publish the configuration:
php artisan vendor:publish --tag="halaxy-config"
Set your credentials in .env (created per practice in Halaxy's developer settings):
HALAXY_CLIENT_ID=your-client-id HALAXY_CLIENT_SECRET=your-client-secret HALAXY_REGION=au # au or eu
Usage
Patients
use Clinically\Halaxy\Facades\Halaxy; // Find, list, create $patient = Halaxy::patients()->find('123456')->json(); $bundle = Halaxy::patients()->list(['_count' => 30]); $created = Halaxy::patients()->create([ 'name' => [['use' => 'official', 'given' => ['John'], 'family' => 'Doe']], 'birthDate' => '1990-01-15', ]); // Partial update (JSON merge-patch) Halaxy::patients()->update('123456', [ 'telecom' => [['system' => 'phone', 'value' => '0400000000', 'use' => 'mobile']], ]); // Full replace (PUT) — destructive: writable properties omitted from the // payload are REMOVED from the profile (only file attachments survive). // Always send the complete desired state. Halaxy::patients()->replace('123456', [ 'name' => [['use' => 'official', 'given' => ['John'], 'family' => 'Doe']], 'birthDate' => '1990-01-15', 'telecom' => [['system' => 'phone', 'value' => '0400000000', 'use' => 'mobile']], ]); // Export references for every patient in the practice $references = Halaxy::patients()->exportIds()->json();
Query Builder
$paginator = Halaxy::patients() ->query() ->where('family', 'Doe') ->where('_lastUpdated', 'gt', '2024-01-01') ->orderBy('family') ->paginate(50); foreach ($paginator->items() as $patient) { // current page } foreach ($paginator->all() as $patient) { // lazily fetches every page }
Appointments
Booking uses Halaxy's $book operation, which expects a FHIR Parameters
resource (not a bare Appointment) and creates related resources (invoice,
clinical note) as part of the booking:
// Find available times (start, end, and duration are required) $available = Halaxy::appointments()->findAvailable([ 'start' => '2026-02-15T09:00:00+11:00', 'end' => '2026-02-22T17:00:00+11:00', 'duration' => '30', 'show' => 'first-available', ]); // Book $booked = Halaxy::appointments()->book([ 'parameter' => [ [ 'name' => 'appt-resource', 'resource' => [ 'resourceType' => 'Appointment', 'start' => '2026-02-15T09:00:00+11:00', 'end' => '2026-02-15T09:30:00+11:00', 'minutesDuration' => 30, 'participant' => [ ['actor' => ['type' => 'PractitionerRole', 'reference' => 'https://au-api.halaxy.com/main/PractitionerRole/PR-123']], ], ], ], ['name' => 'patient-id', 'valueReference' => ['type' => 'Patient', 'reference' => 'https://au-api.halaxy.com/main/Patient/123456']], ['name' => 'healthcare-service-id', 'valueReference' => ['type' => 'HealthcareService', 'reference' => 'https://au-api.halaxy.com/main/HealthcareService/789']], ['name' => 'location-type', 'valueCode' => 'clinic'], ['name' => 'status', 'valueCode' => 'booked'], ], ]); // Update (merge-patch): description, start/end, comment, participant, ... Halaxy::appointments()->update($appointmentId, ['comment' => 'Running late']);
Appointments have no writable status field. Cancellation is done by
patching the patient participant's modifierExtension:
Halaxy::appointments()->update($appointmentId, [ 'participant' => [[ 'actor' => ['type' => 'Patient', 'reference' => "https://au-api.halaxy.com/main/Patient/{$patientId}"], 'modifierExtension' => [[ 'url' => 'https://terminology.halaxy.com/StructureDefinition/appointment-participant-status', 'valueCoding' => [ 'system' => 'https://au-api.halaxy.com/presets/CodeSystem/appointment-participant-status', 'code' => 'cancelled (no charge)', 'display' => 'cancelled (no charge)', ], ]], ]], ]);
Referrals
Halaxy referrals use Halaxy-specific property names rather than the generic
FHIR ServiceRequest names. A Coverage record must already exist. Use the typed
payload to produce coverage, created, active, comment, and attachments:
use Clinically\Halaxy\DTOs\ReferralAttachment; use Clinically\Halaxy\DTOs\ReferralPayload; $referral = Halaxy::referrals()->create(new ReferralPayload( coverageReference: "Coverage/{$coverageId}", subjectReference: "Patient/{$patientId}", requesterReference: "PractitionerRole/{$requesterId}", created: new DateTimeImmutable('now'), comment: 'Specialist review requested', attachments: [ new ReferralAttachment('application/pdf', $base64Data, 'referral.pdf'), ], ));
Halaxy's referral PATCH endpoint only appends attachments. It does not update or replace other referral properties:
Halaxy::referrals()->addAttachments( $referralId, new ReferralAttachment('application/pdf', $base64Data, 'additional.pdf'), );
Schedules and Slots
$schedules = Halaxy::schedules()->list(); // Generate slots for one schedule via its $generate operation Halaxy::schedules()->generateSlots('33001', [ 'period' => [ 'start' => '2026-02-01T09:00:00+11:00', 'end' => '2026-02-08T17:00:00+11:00', // at least 24h after start ], ]); $slots = Halaxy::slots()->list(['schedule' => 'Schedule/33001']);
Region Switching
$response = Halaxy::region('eu')->patients()->find('789');
Multi-Tenant Usage
Each tenant (e.g. clinic) can use its own Halaxy credentials with isolated OAuth token caching:
$halaxy = Halaxy::for( tenantId: $clinic->id, clientId: $clinic->halaxy_client_id, clientSecret: $clinic->halaxy_client_secret, region: 'au', ); $patients = $halaxy->patients()->list(); // One-off credentials (not cached by tenant) $halaxy = Halaxy::withCredentials(clientId: '...', clientSecret: '...', region: 'eu'); // Cache management Halaxy::forgetTenant($clinic->id); Halaxy::flush();
Direct Client Access
$client = Halaxy::getClient(); $response = $client->get('Patient', ['family' => 'Doe']);
Available Resources
| Group | Method | Operations |
|---|---|---|
| People | patients() |
find, list, create, update, replace, exportIds |
| People | practitioners() |
find, list, create |
| People | practitionerRoles() |
find, list, create |
| People | organizations() |
find, list, create |
| Scheduling | appointments() |
find, list, book/create, update, findAvailable |
| Scheduling | schedules() |
find, list, create, generateSlots |
| Scheduling | slots() |
find, list |
| Scheduling | healthcareServices() |
find, list |
| Financial | chargeItemDefinitions() |
find, list |
| Financial | coverages() |
find, list, create, update |
| Financial | invoices() |
find, list |
| Financial | invoiceLines() |
find, list |
| Financial | paymentTransactions() |
find, list |
| Financial | referrals() |
find, list, create, addAttachments |
| Financial | referralDefinitions() |
find, list |
| Clinical | documentReferences() |
create |
| Foundations | capabilities() |
get |
| Foundations | searchParameters() |
list |
The SDK deliberately exposes only the operations Halaxy documents — there is
no delete() anywhere because the Halaxy API has no delete endpoint, and
replace() (PUT) exists only on patients.
Halaxy API Quirks
Verified against the live API — worth knowing before you integrate:
- Nothing can be deleted. No resource has a delete endpoint. Records you create (patients, contacts, notes) persist until removed via the Halaxy UI.
- Patient
identifierandactiveare read-only. Identifiers sent on create are silently dropped, so you cannot tag patients for later lookup; store Halaxy patient IDs on your side. Writes toactiveare silently ignored — archiving is UI-only. - PUT replace is destructive. Writable properties omitted from a
replace()payload are removed. Send the complete desired state. - Appointment status is not directly writable. Book with the
statusparameter; cancel via the participantmodifierExtension(see above). - Polymorphic search parameters need
Type/idvalues. e.g.recipient=Patient/123— a bare ID returns HTTP 422. Some documented patient-scoping parameters are silently ignored by the server; verify filters against a control query before trusting them. - A
User-Agentheader is mandatory. Halaxy's gateway rejects requests without one (HTTP 403). The SDK always sendshalaxy.user_agent.
Webhooks
The SDK integrates spatie/laravel-webhook-client to receive Halaxy webhooks.
Webhook handling is disabled by default and must be explicitly enabled with a
signing secret. Requests fail closed if the secret is missing.
Halaxy webhook payloads identify which resource changed but not what happened to it, so each event type gets its own endpoint. When creating each webhook in Halaxy (Settings > Integrations > Webhooks), point it at the URL matching its event:
| Halaxy event | Endpoint |
|---|---|
| Patient Create | https://your-app/webhooks/halaxy/patient-created |
| Patient Update | https://your-app/webhooks/halaxy/patient-updated |
| Appointment Create | https://your-app/webhooks/halaxy/appointment-created |
| Appointment Update | https://your-app/webhooks/halaxy/appointment-updated |
| Appointment Delete | https://your-app/webhooks/halaxy/appointment-deleted |
| Invoice Create | https://your-app/webhooks/halaxy/invoice-created |
| Invoice Update | https://your-app/webhooks/halaxy/invoice-updated |
| Invoice Delete | https://your-app/webhooks/halaxy/invoice-deleted |
Set the webhook's "Authentication Header" in Halaxy to your
HALAXY_WEBHOOK_SECRET value.
HALAXY_WEBHOOKS_ENABLED=true HALAXY_WEBHOOK_SECRET=your-webhook-secret HALAXY_WEBHOOK_PATH=webhooks/halaxy
Warning
HALAXY_WEBHOOKS_ENABLED=true without HALAXY_WEBHOOK_SECRET rejects every
webhook request. Configure the same secret as the webhook's Authentication
Header in Halaxy before enabling the endpoints.
Exclude the webhook routes from CSRF verification in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware): void { $middleware->validateCsrfTokens(except: ['webhooks/halaxy', 'webhooks/halaxy/*']); })
Run the spatie migration to create the webhook_calls table:
php artisan vendor:publish --provider="Spatie\WebhookClient\WebhookClientServiceProvider" --tag="webhook-client-migrations" php artisan migrate
Each endpoint dispatches its typed event — PatientCreated, PatientUpdated,
AppointmentCreated, AppointmentUpdated, AppointmentDeleted,
InvoiceCreated, InvoiceUpdated, InvoiceDeleted — exposing
resourceReference, timestamp, webhookCall, and an ID accessor
(e.g. patientId()). Requests to the base path dispatch the generic
HalaxyWebhookReceived.
Multi-tenant hosts
Disable auto-registration and register the routes inside your own tenant-scoped group:
HALAXY_WEBHOOK_ROUTES=false
// e.g. routes/tenant-webhooks.php, grouped under {tenant} + tenant middleware use Clinically\Halaxy\Webhooks\HalaxyWebhookRoutes; HalaxyWebhookRoutes::register('halaxy');
Point each Halaxy webhook at the tenant URL, e.g.
https://your-app/app/{tenant}/webhooks/halaxy/appointment-created.
For per-tenant secrets, substitute your own validator (and any other
component) in config/halaxy.php:
'webhooks' => [ // ... 'signature_validator' => App\Webhooks\TenantHalaxySignatureValidator::class, ],
Entries named halaxy/halaxy-* in a host's published webhook-client.php
are superseded by the package's entries — put overrides in
config/halaxy.php's webhooks block instead.
Error Handling
use Clinically\Halaxy\Exceptions\AuthenticationException; use Clinically\Halaxy\Exceptions\NotFoundException; use Clinically\Halaxy\Exceptions\RateLimitException; use Clinically\Halaxy\Exceptions\ServerException; use Clinically\Halaxy\Exceptions\ValidationException; try { $response = Halaxy::patients()->find('invalid-id'); } catch (NotFoundException $e) { // 404 } catch (ValidationException $e) { // 400/422 — $e->operationOutcome holds the FHIR OperationOutcome issues } catch (AuthenticationException $e) { // 401/403 — message includes the OAuth error detail } catch (RateLimitException $e) { $retryAfter = $e->retryAfter; }
Testing
composer test # Unit + Feature suites (HTTP is faked) composer test:unit composer test:feature composer analyse # PHPStan composer lint # Pint
Integration tests (live API)
The Integration suite exercises the real Halaxy API and is excluded from
composer test. To run it, copy .env.example to .env, add credentials
for a practice with curated test patients, and pin their IDs:
HALAXY_CLIENT_ID=... HALAXY_CLIENT_SECRET=... HALAXY_TEST_PATIENT_IDS=111,222 # first = "Test, Patient", second = "Test, Another" HALAXY_ALLOW_WRITES=false # true enables write tests (see .env.example)
composer test:integration
Tests skip cleanly when credentials are absent. Write tests are gated behind
HALAXY_ALLOW_WRITES, only ever touch the pinned test patients, and clean up
after themselves where the API allows (booked appointments are cancelled);
endpoints that would create undeletable records in the practice are skipped
by design — see tests/Integration/UnexercisedWriteEndpointsTest.php.
License
MIT License. See LICENSE for details.