opusdns / php-client
PHP client for the OpusDNS API, generated from the OpenAPI specification
Requires
- php: ^8.3
- ext-json: *
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
Requires (Dev)
- guzzlehttp/guzzle: ^7.8
- nette/php-generator: ^4.1
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.5 || ^12.0
- symfony/yaml: ^7.0
Suggests
- guzzlehttp/guzzle: Provides the PSR-18 client and PSR-17 factories that Client::create() uses by default
Provides
None
Conflicts
None
Replaces
None
README
The official PHP client for the OpusDNS API: domain registration, transfers, renewals, contacts, DNS zones, email and domain forwarding, and everything else the API offers. The client is generated from the OpenAPI specification published in OpusDNS/api-spec, so every operation, model and enumeration of the API is available with full type information.
Requirements
- PHP 8.3 or newer
- A PSR-18 HTTP client and PSR-17 factories. Guzzle is used automatically when it is installed.
- An OpusDNS API key
Installation
composer require opusdns/php-client guzzlehttp/guzzle
Leave out guzzlehttp/guzzle if you provide your own PSR-18 client.
Quick start
use OpusDNS\Client\Client; use OpusDNS\Client\Config; use OpusDNS\Client\Enum\PeriodUnit; use OpusDNS\Client\Enum\RenewalMode; use OpusDNS\Client\Model\ContactHandle; use OpusDNS\Client\Model\DomainCreate; use OpusDNS\Client\Model\DomainPeriod; use OpusDNS\Client\Model\Nameserver; $client = Client::create(Config::sandbox('opk_your_api_key')); // Is the name available? $check = $client->domain()->eppCheckDomain(['example.com']); if (!$check->results[0]->available) { exit('taken'); } // Register it for one year with an existing contact. $domain = $client->domain()->createDomain(new DomainCreate( contacts: ['registrant' => [new ContactHandle('contact_01h45ytscbebyvny4gc8cr8ma2')]], name: 'example.com', period: new DomainPeriod(PeriodUnit::Y, 1), renewalMode: RenewalMode::EXPIRE, nameservers: [new Nameserver('ns1.example.net'), new Nameserver('ns2.example.net')], )); echo $domain->expiresOn?->format('Y-m-d');
Use Config::production() for the live API.
Configuration
use OpusDNS\Client\Client; use OpusDNS\Client\Config; $config = new Config( apiKey: 'opk_...', baseUrl: Config::PRODUCTION_URL, // or Config::SANDBOX_URL userAgent: 'my-billing-app/2.0', // defaults to the client token headers: ['X-Request-Source' => 'billing'], timeout: 30.0, // seconds, applied when Client::create() builds Guzzle connectTimeout: 5.0, clientToken: 'my-billing-app/2.0', // sent as X-OpusDNS-Client; '' omits the header ); // Guzzle, built by the client $client = Client::create($config); // Any PSR-18 client with PSR-17 request and stream factories $client = new Client($config, $psr18Client, $requestFactory, $streamFactory);
Config::production($apiKey) and Config::sandbox($apiKey) are shortcuts for the two environments.
Every request carries the header X-OpusDNS-Client: opusdns-php-client/<version>.
Services
The API is grouped into services, one per tag of the specification. Each service is reached through an accessor on the client and has one method per operation, named after the operation id.
| Accessor | Service | Covers |
|---|---|---|
$client->domain() |
DomainService |
Registration, transfer, renewal, restore, update, deletion, availability checks, claims notices, DNSSEC at the registry, TLD-specific procedures |
$client->contact() |
ContactService |
Registrant and other contacts, verification, attribute sets |
$client->availability() |
AvailabilityService |
Bulk and streamed availability lookups |
$client->domainSearch() |
DomainSearchService |
Name suggestions |
$client->tld() |
TldService |
TLD specifications and portfolio |
$client->host() |
HostService |
Host objects (glue records) |
$client->dns() |
DnsService |
DNS zones, record sets, DNSSEC signing, zone summaries |
$client->vanityNameservers() |
VanityNameserversService |
Vanity nameserver sets |
$client->domainForward() |
DomainForwardService |
HTTP redirects |
$client->emailForward() |
EmailForwardService |
Email forwarding |
$client->parking() |
ParkingService |
Domain parking |
$client->jobs() |
JobsService |
Asynchronous jobs and batches |
$client->event() |
EventService |
Event history |
$client->tag() |
TagService |
User tags |
$client->archive() |
ArchiveService |
Archived objects |
$client->report() |
ReportService |
Reports and downloads |
$client->organization() |
OrganizationService |
Organization, credentials, pricing, billing |
$client->user() |
UserService |
Users of the organization |
$client->whitelabel() |
WhitelabelService |
White-label branding |
$client->authentication() |
AuthenticationService |
Token and credential introspection |
$client->aiConcierge() |
AiConciergeService |
AI concierge conversations |
A few examples:
$domain = $client->domain()->getDomain('example.com'); $renewal = $client->domain()->renewDomain('example.com', new DomainRenewRequest( currentExpiryDate: new \DateTimeImmutable('2027-01-31T10:00:00Z'), period: new DomainPeriod(PeriodUnit::Y, 1), )); echo $renewal->newExpiryDate->format('Y-m-d'); $client->domain()->transferDomain(new DomainTransferIn('example.org', RenewalMode::RENEW, authCode: 'epp-code')); $client->domain()->updateDomain('example.com', new DomainUpdate(renewalMode: RenewalMode::RENEW)); $contact = $client->contact()->createContact(new ContactCreate( city: 'Berlin', country: 'DE', disclose: false, email: 'jane@example.com', firstName: 'Jane', lastName: 'Doe', phone: '+49.301234567', postalCode: '10115', street: 'Unter den Linden 1', )); $zone = $client->dns()->getZone('example.com'); $client->dns()->patchZoneRrsets('example.com', new DnsZoneRrsetsPatchOps([ new DnsRrsetPatchOp(PatchOp::UPSERT, new DnsRrsetPatch('www.example.com.', [['rdata' => '192.0.2.1']], 3600, DnsRrsetType::A)), ]));
Method parameters follow the specification: path parameters first, then required query parameters and the
request body, then optional query parameters. Optional parameters left at null are not sent. Named arguments
keep calls readable:
$page = $client->domain()->getDomains(search: 'example', expiresIn30Days: true, pageSize: 50);
Models
Every request and response schema is a final readonly class in OpusDNS\Client\Model, and every enumeration
is a backed enum in OpusDNS\Client\Enum.
- Properties are public and camelCase:
$domain->expiresOn,$zone->dnssecStatus. The JSON keys stay snake_case and are mapped byfromArray()andtoArray(). - Construct request models with named arguments. Required fields come first and have no default; optional fields
default to
nullor to the default the specification declares. - Every service method that takes a body accepts either the model or a plain array with the JSON field names.
- Models implement
JsonSerializable, sojson_encode($domain)and framework JSON responses produce the API's own shape. toArray()omits properties that arenull.new DomainUpdate(authCode: null)therefore leaves the auth code untouched. To send an explicitnull, pass a plain array:updateDomain('example.com', ['auth_code' => null]).- Enumerated fields are typed as the enum or its raw value, for example
RenewalMode|string. Known values hydrate to the enum case, so$domain->renewalMode === RenewalMode::EXPIREworks; a value this version of the client does not know is kept as the raw string, somatchexpressions need a default arm. - Timestamps are
\DateTimeImmutableobjects; they are sent as UTC RFC 3339 strings with aZsuffix. Date-only fields are\DateTimeImmutableobjects at midnight UTC and are sent asYYYY-MM-DD, whatever the process timezone. - Nested objects are models, lists are PHP lists and maps are string-keyed arrays, all documented with generic
types in the constructor docblock. An empty map is sent as
{}. - Schemas that are a union of objects become PHP union types. The client resolves them with the discriminator the specification declares, or by matching required keys when there is none.
Pagination
Listing methods return a page model that implements Page: results() gives the items of that page and
pagination() the metadata (currentPage, totalPages, totalItems, hasNextPage). Paginator walks all
pages by calling your closure with successive page numbers.
use OpusDNS\Client\Paginator; $expiring = Paginator::items(fn (int $page) => $client->domain()->getDomains(page: $page, pageSize: 100, expiresIn30Days: true)); foreach ($expiring as $domain) { echo $domain->name, ' expires ', $domain->expiresOn?->format('Y-m-d'), "\n"; } foreach (Paginator::pages(fn (int $page) => $client->dns()->listZones(page: $page), maxPages: 5) as $number => $page) { echo "page {$number} of {$page->pagination()->totalPages}\n"; }
Iteration stops when the API reports no further page, when the last page number is reached, when a page comes
back empty, or after maxPages pages.
Errors
All exceptions extend OpusDNS\Client\Exception\OpusDnsException, which extends RuntimeException.
| Exception | Raised when |
|---|---|
NetworkException |
No response arrived: DNS, TLS, connection or timeout failure |
HttpException |
The API answered with a 4xx or 5xx status |
BadRequestException |
400 |
UnauthorizedException |
401, usually a missing or invalid API key |
ForbiddenException |
403, the key lacks a required permission |
NotFoundException |
404 |
ConflictException |
409, for example registering a name that is already registered |
ValidationException |
422; messages() lists the failing fields as field.path: message |
RateLimitException |
429; retryAfter() gives the seconds to wait when the API sends Retry-After |
ServerException |
Any 5xx |
DecodingException |
A successful response could not be decoded or hydrated into its model |
The status exceptions extend HttpException, which exposes statusCode(), the problem details the API sends
(problemType, problemTitle, problemDetail), the decoded body, and the PSR-7 request and response.
The stored request carries no X-Api-Key header.
use OpusDNS\Client\Exception\ConflictException; use OpusDNS\Client\Exception\HttpException; use OpusDNS\Client\Exception\NetworkException; use OpusDNS\Client\Exception\ValidationException; try { $client->domain()->createDomain($request); } catch (ValidationException $e) { foreach ($e->messages() as $message) { echo $message, "\n"; } } catch (ConflictException $e) { echo $e->problemDetail; } catch (HttpException $e) { echo $e->statusCode(), ' ', $e->problemTitle; } catch (NetworkException $e) { // retry later }
Testing your integration
OpusDNS\Client\Testing\FakeHttpClient is a PSR-18 client that records requests and answers from a queue, so
code using this client can be tested without the network:
use OpusDNS\Client\Config; use OpusDNS\Client\Testing\FakeHttpClient; $http = new FakeHttpClient(); $client = $http->client(Config::sandbox('test-key')); $http->queueJson(200, ['name' => 'example.com', 'roid' => 'D1-OPUS', 'sld' => 'example', 'tld' => 'com']); $domain = $client->domain()->getDomain('example.com'); $http->lastRequest()->getUri(); // https://sandbox.opusdns.com/v1/domains/example.com $http->requests; // every request sent so far
queue() accepts any PSR-7 response or a throwable to simulate a transport failure. An empty queue answers
200 with an empty JSON object.
Lower-level access
Client::request() sends any request with the configured authentication and returns the PSR-7 response.
Endpoint holds every path template of the API, and Permission::required() tells which permissions the API
key needs for an operation.
use OpusDNS\Client\Endpoint; use OpusDNS\Client\Permission; $response = $client->request('GET', Endpoint::DOMAINS_BY_DOMAIN_REFERENCE, path: ['domain_reference' => 'example.com']); $data = $client->decodeArray($response); Permission::required('POST', Endpoint::DOMAINS); // ["domains:manage"]
Development
Layout
| Path | Contents |
|---|---|
src/Client.php, Config.php, Serializer.php, Union.php, Paginator.php, ApiModel.php, src/Exception/ |
Hand-written runtime |
src/Model/, src/Enum/, src/Service/, src/Endpoint.php, src/Permission.php |
Generated from the specification; each file says so in its header |
generator/ |
The generator, built on nette/php-generator; a development dependency only |
spec/openapi.yaml |
The specification the generated code was produced from |
spec/version.yaml |
The api-spec commit and version that specification was taken from |
tests/ |
PHPUnit tests, including a fixture specification that exercises the generator end to end |
Examples
examples/ holds scripts that run against the sandbox and print what happens: registering, renewing and updating
a domain. They read the key from the OPUSDNS_API_KEY environment variable or from a .env file in the
repository root; copy .env.example to .env and fill in a sandbox key.
php examples/register-domain.php php examples/renew-domain.php opusdns-php-client-1234abcd.com php examples/update-domain.php opusdns-php-client-1234abcd.com
Commands
composer install composer spec:check # is spec/openapi.yaml behind api-spec? composer spec:sync # fetch the latest specification (or: bin/spec-sync --ref <commit>) composer generate # regenerate src/ from spec/openapi.yaml composer test # PHPUnit composer stan # PHPStan over src and generator
Updating to a new specification
Run composer spec:sync, then composer generate, composer test and composer stan, and review the diff of
src/. New operations, models and enum values appear automatically; removed or renamed ones are a breaking
change for consumers. Regenerate whenever the API adds enum values: hydration is strict, so a client built from
an older specification keeps values it has never seen as raw strings.
The "Spec update" workflow does the same on a weekly schedule and on manual runs: it syncs the specification,
regenerates, runs the checks and opens a pull request when anything changed. The "CI" workflow verifies on every
push and pull request that src/ matches the committed specification and that the checks pass.
Releasing
Merging a pull request into main releases: the "Release" workflow runs the checks, bumps the version from the
latest tag, pushes the new tag and creates the GitHub release with generated notes. The pull request's label
picks the bump: major, minor or patch, with patch when no label is set; a no-release label skips the
release. The same workflow can be run by hand with the bump chosen from a dropdown. Packagist picks the tag up
through its GitHub hook.
How the generator maps the specification
| Specification | PHP |
|---|---|
| Object schema | final readonly class with promoted public properties, fromArray() and toArray() |
| Enum schema | Backed enum; case names are upper snake case, values starting with a digit get a leading underscore |
anyOf with null |
Nullable type |
| Union of objects | PHP union type, resolved by discriminator or by required keys |
array, additionalProperties |
array, documented as list<T> or array<string, T> |
date-time, date |
\DateTimeImmutable |
default, const |
Constructor default values |
| Operation | Method on the tag's Service\*Service class, named from the operation id |
| Path | Constant on Endpoint |
x-required-permissions |
Permission::REQUIRED and a line in the method docblock |
License
Released under the MIT License. See LICENSE for the full text.