oleksyuk / apaleo-php
Framework-agnostic PHP SDK for the Apaleo hotel PMS API
Requires
- php: ^8.4
- ext-mbstring: *
- psr/clock: ^1.0
- psr/http-client: ^1.0
- psr/http-factory: ^1.1
- psr/http-message: ^2.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95.27
- nyholm/psr7: ^1.8.2
- php-http/mock-client: ^1.6.1
- phpstan/phpstan: ^2.3.1
- phpstan/phpstan-deprecation-rules: ^2.0.5
- phpunit/phpunit: ^13.4.3
- psr/simple-cache: ^3.0
- rector/rector: ^2.7
- symfony/clock: ^8.0
- symfony/http-client: ^8.0
Suggests
- psr/simple-cache: Required by Psr16TokenCache to share the access token through a PSR-16 cache.
- symfony/http-client: Lets RequestPipeline::sendMany() dispatch requests concurrently instead of one by one.
Provides
None
Conflicts
None
Replaces
None
README
apaleo-php
Framework-agnostic PHP SDK for the Apaleo hotel PMS API.
Installation
composer require oleksyuk/apaleo-php
Bring any PSR-18 HTTP client and PSR-17 factories, e.g. symfony/http-client + nyholm/psr7, or guzzlehttp/guzzle.
Usage
use Oleksyuk\Apaleo\ApaleoClient; use Oleksyuk\Apaleo\Auth\ClientCredentialsTokenProvider; use Symfony\Component\HttpClient\Psr18Client; $http = new Psr18Client(); // also serves as the PSR-17 request/stream factory $tokenProvider = new ClientCredentialsTokenProvider($http, $http, $http, 'your-client-id', 'your-client-secret'); $apaleo = new ApaleoClient($http, $http, $http, $tokenProvider); $properties = $apaleo->inventory()->properties()->list(); foreach ($properties as $property) { echo $property->id, ' — ', implode(', ', $property->name), "\n"; }
With Guzzle, pass new GuzzleHttp\Client() as the client and new GuzzleHttp\Psr7\HttpFactory() as both factories. Inside a DI container, wire the same services the container already has.
Covered APIs
| Method | Apaleo API |
|---|---|
account() |
Account |
availability() |
Availability |
booking() |
Booking |
finance() |
Finance |
inventory() |
Inventory |
logs() |
Logs |
operations() |
Operations |
ratePlan() |
Rate Plan |
reports() |
Reports |
settings() |
Settings |
Anything else goes through send().
Timeouts and retries
The SDK doesn't retry on its own: that's the HTTP client's job. With symfony/http-client, this matches what apaleo-bundle sets up: a 10 s timeout, and 5xx/transport errors retried only for GET/HEAD, because a 502 after a POST may still have been applied. 429 is deliberately not retried there: the client would sleep for whatever Retry-After says, uncapped. Catch ApaleoRateLimitException and use its retryAfterSeconds to decide whether to wait.
use Oleksyuk\Apaleo\ApaleoClient; use Oleksyuk\Apaleo\Auth\ClientCredentialsTokenProvider; use Symfony\Component\HttpClient\HttpClient; use Symfony\Component\HttpClient\Psr18Client; use Symfony\Component\HttpClient\Retry\GenericRetryStrategy; use Symfony\Component\HttpClient\RetryableHttpClient; $safe = ['GET', 'HEAD']; $http = new RetryableHttpClient( HttpClient::create(['timeout' => 10]), new GenericRetryStrategy([0 => $safe, 500 => $safe, 502 => $safe, 503 => $safe, 504 => $safe]), maxRetries: 2, ); $psr18 = new Psr18Client($http); // also serves as the PSR-17 request/stream factory $tokenProvider = new ClientCredentialsTokenProvider($psr18, $psr18, $psr18, 'your-client-id', 'your-client-secret'); $apaleo = new ApaleoClient($psr18, $psr18, $psr18, $tokenProvider, asyncHttpClient: $http);
Passing asyncHttpClient also lets sendMany() run its requests concurrently. With Guzzle, use its Middleware::retry() with the same rules.
Token cache
By default, the access token is kept in memory, which under PHP-FPM means a new token request on every HTTP request. Share it through any PSR-16 cache (needs psr/simple-cache, which PSR-16 implementations such as symfony/cache don't always pull in):
use Oleksyuk\Apaleo\Auth\Psr16TokenCache; $tokenProvider = new ClientCredentialsTokenProvider( $http, $http, $http, 'your-client-id', 'your-client-secret', new Psr16TokenCache($psr16Cache) );
Or implement TokenCacheInterface on top of your framework's own storage.
On a 401 the SDK drops the cached token, fetches a new one and retries the request at once.
Errors
Everything the SDK throws implements ApaleoExceptionInterface, so one catch covers it:
| Exception | When |
|---|---|
ApaleoValidationException |
400 / 422, with messages |
ApaleoAuthException |
401 / 403 |
ApaleoNotFoundException |
404 |
ApaleoRateLimitException |
429, with Retry-After |
ApaleoClientException |
any other 4xx (e.g. 409); parent of the four above |
ApaleoServerException |
5xx, including HTML bodies from a proxy |
ApaleoTransportException |
timeout, DNS, connection refused: no HTTP status, statusCode doesn't exist |
ApaleoUnexpectedResponseException |
a 2xx with a non-JSON or malformed body |
The HTTP ones extend ApaleoException and carry statusCode (also returned by getCode()), apaleoErrorType and rawResponse. Invalid arguments (an Unknown enum value in a filter, a body that can't be JSON-encoded) throw \InvalidArgumentException before anything is sent.
Pagination
list() returns a PaginatedResult (items, totalCount). pageSize is at most 500. To walk every page lazily:
use Oleksyuk\Apaleo\Support\Paginator; foreach (Paginator::all(fn (int $page) => $apaleo->inventory()->units()->list(pageNumber: $page, pageSize: 500)) as $unit) { // ... }
Pages are offsets, so don't change what the filter matches while walking them: if you archive or re-status items as you go, the next page shifts and skips some. Collect the IDs first, then act on them.
Idempotency
Every method that creates something (bookings, reservations, authorizations, payments, charges, folios, blocks, groups, inventory, rate plans, settings...) accepts an optional $idempotencyKey. Pass the same key when retrying a POST after a timeout, so Apaleo doesn't apply it twice: without one, a retried authorization can charge a guest's card twice.
$key = bin2hex(random_bytes(16)); // generate once per logical operation, reuse on retry $apaleo->booking()->authorizations()->createByTerminal($target, $amount, $terminalId, idempotencyKey: $key);
Requests the SDK doesn't cover
send() and sendMany() take low-level Request objects, either SDK ones or your own subclass, and return the decoded body:
use Oleksyuk\Apaleo\Http\Enum\Method; use Oleksyuk\Apaleo\Http\Request; final readonly class GetThingRequest extends Request { public function __construct(private string $id) {} public function method(): Method { return Method::GET; } public function endpoint(): string { return '/some/v1/things/'.rawurlencode($this->id); } } $data = $apaleo->send(new GetThingRequest('X'));
For a binary endpoint (PDF, CSV), use sendRaw(): it returns the body as-is, while errors still map to the usual exceptions. Override Request::accept() with the media type you expect.
sendMany() runs the requests concurrently (see asyncHttpClient above) and is all-or-nothing: if one fails, its exception is thrown and the other results are lost.
Good to know
- Unrecognized enum values. Every response enum has an
Unrecognizedcase, so a value Apaleo adds later doesn't break parsing. You can't send it back: filters reject it. - Shared types. Types that are identical in every API that uses them (
MonetaryValue,EmbeddedProperty,ChannelCode,UnitGroupType, ...) live once inOleksyuk\Apaleo\Resource\Shared, so a value read from one API can be passed straight to another. Types whose shape differs per API (e.g.GuaranteeType) stay in that API's namespace. - Strings are trimmed. Many fields are typed in by hotel staff and carry stray whitespace. Optional strings that end up empty become
null. - Dates. Date-only fields (
serviceDate,arrivalin offers, ...) are midnight UTCDateTimeImmutables, soformat('Y-m-d')gives back the same day in anydate.timezone. Date-time fields keep Apaleo's offset. - Money is a
float. JSON has already lost precision by the time the SDK sees it. Don't sumamountvalues directly: round withround($x, 2)or convert to minor units first.
Development
task lint # PHPStan (max level), PHP CS Fixer, Rector, composer validate task test # PHPUnit task test:coverage # PHPUnit + coverage report in build/ (needs Xdebug or PCOV) task fix # auto-fix CS Fixer / Rector issues