Aller au contenu

omnisense/omnidns

Omnidns: a domain name where it is registered, under any PHP host - whether it is free and at what price, registering and renewing it, the account's domains and their terms, their name servers, and the records of their zones; from OVH, Gandi, Cloudflare or Scaleway through one set of models. Providers are packages of their own (omnidns/ovh, omnidns/gandi, omnidns/cloudflare, omnidns/scaleway); no framework in require.

Étage
Famille
Version
1.x
Vérifié en réel
Vérifié en partie
Licence
MIT
composer require omnisense/omnidns:1.x-dev

Omnidns handles domains where they are registered and served, under any PHP host: whether a domain is free and at what price, registering and renewing it, the domains an account holds and when each ends, their name servers, and the records of their zones - at OVHcloud, Gandi, Cloudflare or Scaleway, through one set of models.

$registry->availability('ovh')->check('example.fr');                 // Model\Availability: free, 8.39 EUR
$registry->registrar('gandi')->register('example.fr', 2, $owner);    // Model\Order
$registry->domains('scaleway')->domain('example.fr')->daysLeft();    // 126
$registry->nameservers('ovh')->setNameservers('example.fr', ['ns1.example.net', 'ns2.example.net']);
$registry->dns('cloudflare')->add('example.fr', new Record('A', 'www', '192.0.2.10'));

Each provider implements the contracts it can answer - AvailabilityInterface, RegistrarInterface, DomainsInterface, NameserversInterface, DnsInterface - and says so, with the record types its DNS takes and whose terms apply, in its Capabilities. A record is found by its id when the provider gives one, otherwise by its name, type and content: Gandi groups records by name and type and names none, and omnidns hides it. The design is the Omnisense families' one: a provider's factory builds it from options, a registry holds the application's providers by name.

The core is plain PHP: it requires symfony/http-client-contracts (interfaces only) and nothing else. Gandi and Scaleway take the application's HTTP client; OVH and Cloudflare wrap their official or community clients (ovh/ovh, cloudflare/sdk), which send with Guzzle.

Published as omnisense/omnidns, formerly omniscient/omnidns: the package replaces the old name. The namespace is Omnidns\; providers keep the omnidns/ vendor and the Omnidns\<Provider>\ namespace.

When to use it

  • A studio or a hosting back office that manages its clients' domains at several registrars: their terms, their renewals, their name servers.
  • An application that writes DNS records (a client's subdomain, a TXT for a verification, an MX) wherever the zone is served.
  • A domain search with prices, before an order.

It does not read a domain's public registration data (RDAP, WHOIS): that is omnisense/omnistate's omnistate/iana. It keeps nothing: no cache, no zone of its own. It is not namingo/plexdns, a registry and registrar platform's library with its own tables as the source of truth.

register() and renew() are purchases, made in the account's name. Each provider's page says whether an order is paid at once or waits for a payment at Order::$paymentUrl; Gandi's dry_run has Gandi check an order without making it. No provider was run against a live account (Verified, and not).

Providers

Package Factory name Availability Register, renew Domains Name servers Records Client
omnidns/ovh ovh yes, priced yes (an order cart) yes yes yes ovh/ovh (official), Guzzle
omnidns/gandi gandi yes, priced yes yes yes LiveDNS the application's
omnidns/cloudflare cloudflare - - the Registrar's, read (with account_id) - yes cloudflare/sdk, Guzzle
omnidns/scaleway scaleway yes, no price yes yes yes yes the application's
Omnidns\Testing\MemoryFactory (in the core) memory yes yes yes yes yes none

The contracts are what each provider class implements and its capabilities() says (probed in the harness on 2026-10-26). Every DNS takes the eight types of Record::TYPES: A, AAAA, CAA, CNAME, MX, NS, SRV, TXT; a provider's other types (OVH's SPF and DKIM, Gandi's ALIAS, Cloudflare's HTTPS...) are left out of records().

A registrar and a DNS host may be two providers: the domain bought at OVH, its zone served by Cloudflare - two entries of the registry; setNameservers() at the first points the domain at the second.

How this manual is organised

Proposer une modification docs/index.md

Installation

composer require omnisense/omnidns omnidns/gandi symfony/http-client

Each provider is its own package: omnidns/ovh, omnidns/gandi, omnidns/cloudflare, omnidns/scaleway.

Requirements

  • PHP 8.2 or later.
  • omnisense/omnidns requires symfony/http-client-contracts (^3.0), interfaces only, and nothing else.
  • omnidns/gandi and omnidns/scaleway require omnisense/omnidns (1.x) and symfony/http-client-contracts: the application gives the HTTP client (symfony/http-client, or any implementation of the contracts) to the factory's constructor.
  • omnidns/ovh requires ovh/ovh (^3.4, OVHcloud's official client) and guzzlehttp/guzzle (^7.0); ovh/ovh brings league/oauth2-client.
  • omnidns/cloudflare requires cloudflare/sdk (^1.4 or ^2.0) and psr/http-message; the SDK sends with Guzzle.
  • ext-json (always present since PHP 8.0).

Plain PHP

The block below runs as written in the tests (Tests/DocsTest.php, which checks the printed lines against the comments), on the in-memory provider omnidns ships for tests and demos; in production its factory is a provider's (new GandiFactory($httpClient) with ['token' => ...]), the calls the same.

<?php

require 'vendor/autoload.php';

use Omnidns\Model\Money;
use Omnidns\Model\Record;
use Omnidns\Registry;
use Omnidns\Testing\MemoryFactory;

$registry = new Registry([new MemoryFactory()], [
    'registrar' => ['factory' => 'memory', 'options' => ['taken' => ['example.org'], 'price' => Money::fromDecimal('8.39', 'EUR')]],
]);

$check = $registry->availability('registrar')->check('glitchr-example.org');
echo $check->domain, ': ', $check->available ? 'free, '.$check->price?->decimal().' '.$check->price?->currency : 'taken', "\n";
// glitchr-example.org: free, 8.39 EUR

$order = $registry->registrar('registrar')->register('glitchr-example.org', 2);
echo $order->action, ' ', $order->status, ', ends ', $registry->domains('registrar')->domain('glitchr-example.org')->expiresAt?->format('Y-m-d'), "\n";
// register done, ends 2028-01-01

$dns = $registry->dns('registrar');
$dns->add('glitchr-example.org', new Record('A', '', '192.0.2.10', 3600));
$dns->add('glitchr-example.org', Record::fromValue('MX', '', '10 mx.example.net.'));
foreach ($dns->records('glitchr-example.org') as $record) {
    echo $record->fqdn('glitchr-example.org'), ' ', $record->type, ' ', $record->value(), "\n";
}
// glitchr-example.org A 192.0.2.10
// glitchr-example.org MX 10 mx.example.net.

A registrar and a DNS host may be two providers: the domain bought at OVH, its zone served by Cloudflare - two entries of the registry, registrar('ovh') and dns('cloudflare'). setNameservers() at the first points the domain at the second.

Registering and renewing cost money. Each provider's README says whether an order is paid at once or waits for a payment at Order::$paymentUrl; Gandi's dry_run has Gandi check an order without making it.

Building a provider calls nothing: a provider is built the first time the registry is asked for it, its required options checked then (The "gandi" provider needs: token.); a platform is called only by the contracts' methods.

No class of a framework is loaded on the way: Tests/BareTest.php runs the harness's bare script in a process of its own and checks that nothing but the package and the HTTP client contracts was loaded (harness).

A real provider

use Omnidns\Gandi\GandiFactory;
use Omnidns\Ovh\OvhFactory;
use Omnidns\Registry;
use Symfony\Component\HttpClient\HttpClient;

$registry = new Registry([new GandiFactory(HttpClient::create(['timeout' => 20])), new OvhFactory()], [
    'gandi' => ['factory' => 'gandi', 'options' => ['token' => getenv('GANDI_TOKEN'), 'dry_run' => true]],
    'ovh' => ['factory' => 'ovh', 'options' => [
        'application_key' => getenv('OVH_APPLICATION_KEY'),
        'application_secret' => getenv('OVH_APPLICATION_SECRET'),
        'consumer_key' => getenv('OVH_CONSUMER_KEY'),
    ]],
]);

$registry->availability('ovh')->check('example.fr');
$registry->dns('gandi')->records('example.fr', 'MX');

OvhFactory takes a GuzzleHttp\Client (or none: ovh/ovh makes its own), CloudflareFactory a Cloudflare\API\Adapter\Adapter (or none: the SDK's Guzzle adapter on the token).

In a framework

There is no Omnidns bundle and no provider in omnibridge/symfony or omnibridge/laravel yet: build the Registry once where the framework builds its services - Symfony, Laravel.

Proposer une modification docs/installation.md

Symfony

There is no Omnidns bundle, and omnibridge/symfony has no Omnidns wiring yet: a Symfony application declares the registry as a service of its own. Gandi's and Scaleway's factories take the application's http_client by autowiring (Symfony\Contracts\HttpClient\HttpClientInterface); OVH's and Cloudflare's take their own client, optional.

# config/services.yaml
services:
    _defaults:
        autowire: true

    Omnidns\Gandi\GandiFactory: ~
    Omnidns\Scaleway\ScalewayFactory: ~
    Omnidns\Ovh\OvhFactory: ~                 # no argument: ovh/ovh makes its own Guzzle client
    Omnidns\Cloudflare\CloudflareFactory: ~   # no argument: cloudflare/sdk's Guzzle adapter

    Omnidns\Registry:
        arguments:
            $factories:
                - '@Omnidns\Gandi\GandiFactory'
                - '@Omnidns\Ovh\OvhFactory'
                - '@Omnidns\Cloudflare\CloudflareFactory'
            $config:
                registrar:
                    factory: ovh
                    options:
                        application_key: '%env(OVH_APPLICATION_KEY)%'
                        application_secret: '%env(OVH_APPLICATION_SECRET)%'
                        consumer_key: '%env(OVH_CONSUMER_KEY)%'
                dns:
                    factory: cloudflare
                    options: { token: '%env(CLOUDFLARE_API_TOKEN)%' }
# .env.local, or bin/console secrets:set
OVH_APPLICATION_KEY=...
OVH_APPLICATION_SECRET=...
OVH_CONSUMER_KEY=...
CLOUDFLARE_API_TOKEN=...

OvhFactory and CloudflareFactory have an optional constructor argument whose type (GuzzleHttp\Client, Cloudflare\API\Adapter\Adapter) is not a service the application has; if autowiring complains, give them arguments: [~], or wire a client explicitly.

Then inject the registry:

use Omnidns\Model\Record;
use Omnidns\Registry;

final class SubdomainProvisioner
{
    public function __construct(private readonly Registry $domains)
    {
    }

    public function open(string $client): void
    {
        $this->domains->dns('dns')->add('example.org', new Record('CNAME', $client, 'apps.example.org', options: ['proxied' => true]));
    }
}

Nothing is built when the container compiles: a provider is built, and its required options checked, the first time the registry is asked for it. Orders cost money: keep register() and renew() behind an explicit action of a person, not in a message handler that retries.

This page was written from the classes' constructors; it was not run in a Symfony kernel.

Proposer une modification docs/symfony.md

Laravel

omnibridge/laravel has no Omnidns provider: a service provider of the application builds the registry once, from a config file. Gandi and Scaleway take a symfony/http-client client; OVH and Cloudflare send with Guzzle through their own clients (both installed with their packages).

// config/omnidns.php
return [
    'providers' => [
        'registrar' => ['factory' => 'gandi', 'options' => ['token' => env('GANDI_TOKEN')]],
        'dns' => ['factory' => 'cloudflare', 'options' => ['token' => env('CLOUDFLARE_API_TOKEN'), 'account_id' => env('CLOUDFLARE_ACCOUNT_ID')]],
    ],
];
// app/Providers/OmnidnsServiceProvider.php
namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Omnidns\Cloudflare\CloudflareFactory;
use Omnidns\Gandi\GandiFactory;
use Omnidns\Registry;
use Symfony\Component\HttpClient\HttpClient;

final class OmnidnsServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(Registry::class, fn (): Registry => new Registry(
            [new GandiFactory(HttpClient::create(['timeout' => 20])), new CloudflareFactory()],
            config('omnidns.providers'),
        ));
    }
}

Register the provider (bootstrap/providers.php on Laravel 11 and later), then:

use Omnidns\Registry;

$days = app(Registry::class)->domains('registrar')->domain('example.org')->daysLeft();

An unset env() is null, which a required option reports as missing when the provider is first asked for. Configuration caching (php artisan config:cache) keeps working: the options are plain arrays. This page was written from the registry's API; it was not run in a Laravel application.

Proposer une modification docs/laravel.md

Concepts

Provider and its contracts

Omnidns\ProviderInterface is one provider where domains are registered or their DNS served, configured:

Method Returns
getName(): string the factory's name: ovh, gandi, cloudflare, scaleway, memory
getTitle(): string OVHcloud, Gandi, Cloudflare, Scaleway, In memory
capabilities(): Capabilities what it can be asked, the record types its DNS takes, whose terms apply; calls nothing

The contracts, each extending ProviderInterface:

Interface Methods
AvailabilityInterface check(string $domain): Availability
RegistrarInterface register(string $domain, int $years = 1, ?Contact $owner = null): Order, renew(string $domain, int $years = 1): Order
DomainsInterface domains(): list<string>, domain(string $domain): DomainInfo
NameserversInterface nameservers(string $domain): list<string> (host names, no trailing dot), setNameservers(string $domain, list<string> $nameservers): void
DnsInterface records(string $zone, ?string $type = null, ?string $name = null): list<Record>, add(string $zone, Record $record): Record, update(string $zone, Record $current, Record $new): Record, delete(string $zone, Record $record): void

$owner null in register() means the account's default contact, where the provider has one. In records(), $type null is every type, $name (relative to the zone, "" for the apex) null every name. add() answers the record with its id when the provider gives one; update() the record as it now is. update() and delete() take a record with or without an id: without one, it is found among the zone's records by name, type and content (Record::sameAs()).

Capabilities

Omnidns\Capabilities (readonly): availability, registrar, domains, nameservers, dns (bool, default false), recordTypes (list<string>), terms (list<string>, URLs). contracts() lists the contracts it has, toArray() gives {contracts, recordTypes, terms}.

availability registrar domains nameservers dns terms
OVHcloud yes yes yes yes yes OVHcloud's contracts, the API's getting started rules
Gandi yes yes yes yes yes Gandi's terms of service, the API reference
Cloudflare - - with account_id - yes Cloudflare's terms, the API's limits
Scaleway yes yes yes yes yes Scaleway's contracts, the Domains and DNS API
memory yes yes yes yes yes -

Every provider's recordTypes is Record::TYPES. The registry's typed getters (domains(), dns()...) give a provider when it implements the contract and its capabilities say it gives it as configured: Cloudflare without account_id implements DomainsInterface but is refused by domains() (NotSupportedException).

Factory and Config

Omnidns\ProviderFactoryInterface: getName(): string, create(array $options = []): ProviderInterface. Every provider extends Omnidns\ProviderFactory:

  1. createConfig($options): the options in a Config (an ArrayObject), then the factory's populate() sets omnidns.factory_name, omnidns.factory_title, omnidns.required_options and the defaults of the others (Config::defaults() sets only keys not set yet);
  2. the required options are checked: one missing, '' or [] is an InvalidConfigException listing every missing key (The "scaleway" provider needs: secret_key.);
  3. build($config) constructs the provider. Nothing is called.

Config's readers: get($key, $default), string($key) (null when empty), int(), float(), bool() (a bool, or "1", "true", "yes", "on"), validateNotEmpty($keys).

Registry

Omnidns\Registry:

$registry = new Registry([new OvhFactory(), new CloudflareFactory()], [
    'registrar' => ['factory' => 'ovh', 'options' => ['application_key' => '...', 'application_secret' => '...', 'consumer_key' => '...']],
    'dns' => ['factory' => 'cloudflare', 'options' => ['token' => '...']],
]);
Method Returns
get(string $name): ProviderInterface the provider, built the first time and kept
availability(), registrar(), domains(), nameservers(), dns() (each string $name) the provider as that contract; NotSupportedException when it is not one, before any call (The "cloudflare" DNS provider (Omnidns\Cloudflare\CloudflareProvider) does not check availability.)
has(string $name): bool configured or not
options(string $name): array its configured options
names(): list<string> the configured names
factories(): list<string> the factories installed

An unknown name or factory is an InvalidConfigException (No "x" DNS provider; configured: ..., No "x" factory for the "y" DNS provider; installed: ...).

DomainName

Omnidns\Model\DomainName::normalize(string $name): string - lower case, trimmed, no trailing dot; every provider normalizes the domains and zones it is given with it. A name longer than 253 characters, or whose labels a registry would refuse, is an InvalidArgumentException ("exa mple.org" is not a domain name (an internationalized one goes in its xn-- form).): give an internationalized name in its xn-- form.

Record

Omnidns\Model\Record (readonly), one DNS record of a zone:

Property Type
type string one of Record::TYPES: A, AAAA, CAA, CNAME, MX, NS, SRV, TXT (upper-cased; another is an InvalidArgumentException)
name string relative to the zone, lower case, no trailing dot: "" for the apex (@ is read as it), www, _dmarc
content string an address, a host, a text; for an MX the host alone
ttl ?int seconds; null: the zone's (or the provider's) default
priority ?int an MX's priority, apart from its host
id ?string the provider's id, when it gives one (not Gandi)
options array what one provider keeps besides: Cloudflare's proxied
Method
fromValue(string $type, string $name, string $value, ?int $ttl = null, ?string $id = null, array $options = []): self from a zone-file value: an MX's "10 mx.example.net." split into priority and host
value(): string the zone-file value: the MX's priority before its host
fqdn(string $zone): string www.example.org; the zone itself for the apex
relative(string $name, string $zone): string a full name given by a provider (www.example.org.) as relative (www)
normalizeName(string $name): string the name as records hold it
sameAs(Record $other): bool the same id when both have one; else the same type, name and content - a host's case and trailing dot, and the quotes around a text, aside (a TXT's or CAA's case counts); an MX's priority compared when both have one
findIn(iterable $records): ?Record the one that is sameAs()
withId(?string $id): self
toArray()
$mx = Record::fromValue('MX', '@', '10 mx.example.net.');
$mx->toArray();          // {"type":"MX","name":"","content":"mx.example.net.","ttl":null,"priority":10,"id":null,"options":[]}
$mx->value();            // "10 mx.example.net."
$mx->fqdn('example.org');                        // "example.org"
Record::relative('www.example.org.', 'example.org');   // "www"
$mx->sameAs(new Record('MX', '', 'MX.example.net', priority: 10));   // true

(run on 2026-10-26 in the harness)

Availability

Availability (readonly): domain, available (bool), price (?Money, the first year's when the provider quotes one: Gandi's before taxes, OVH's TOTAL), renewal (?Money, the renewal's when the provider says: OVH), premium (bool), reason (?string, why it is not available: registered (a transfer is offered), not orderable, Gandi's status, not available, not offered).

Order

Order (readonly), what a registration or a renewal left with the provider: domain, action (Order::REGISTER register or Order::RENEW renew), status (the provider's word, or the package's: ordered, awaiting payment, checked (dry run)...), id (?string, the order or task), price (?Money), paymentUrl (?string, where to pay when it waits for a payment), years (int).

DomainInfo

DomainInfo (readonly), a domain the account holds: name, expiresAt (?\DateTimeImmutable), autoRenew (?bool), status (list<string>: the registry's or the provider's statuses, ok, clientTransferProhibited...), nameservers (?list<string>, when the provider gives them in the same answer; null: ask NameserversInterface), createdAt. daysLeft(?\DateTimeImmutable $now = null): ?int - days before it ends, negative once it has, null when the provider does not say (an end on 2027-03-01 is 126 days from 2026-10-26).

Contact

Contact (readonly), the registrant: firstName, lastName, email, phone (in the registries' form, +33.123456789), street, city, zip, country (ISO 3166-1 alpha-2), type (Contact::INDIVIDUAL individual, COMPANY company, ASSOCIATION association), organization, handle. Contact::handle('<id>') names a contact the provider already holds (an OVH /me/contact id, a Scaleway contact id); the rest may then stay empty. What each provider takes: Domains.

Money

Money (readonly): int $cents (hundredths of the unit: 8.39 EUR is 839) and an ISO 4217 currency. fromDecimal(), decimal(), multiply(), toArray(); a currency that is not three letters, or an amount that is not numeric, is an InvalidConfigException.

Http\Json

Omnidns\Http\Json::request($http, $method, $url, $options, $provider, $what): the call on the application's client; a network failure is an UnreachableException; a status of 300 or more a ProviderException whose message carries the provider's own words when it gives them (message, errors[0].message or error) and whose code is the status (Gandi answered 404 (The resource could not be found.) for the domain example.org., code 404); an empty body is []; another body that is not JSON a ProviderException. The typed readers - get(), string(), int(), bool(), list(), date() - walk an answer, null when a step is missing.

Proposer une modification docs/concepts.md

Domains

Is it free?

$check = $registry->availability('gandi')->check('example.fr');
if ($check->available) {
    echo $check->price?->decimal(), ' ', $check->price?->currency, $check->premium ? ' (premium)' : '', "\n";
} else {
    echo 'taken: ', $check->reason, "\n";
}
Call Price Not available
OVHcloud the domain's offers in an order cart (GET /order/cart/{id}/domain; one cart opened per provider object, with subsidiary, and kept) the create offer's TOTAL (else PRICE), the renewal its RENEW; premium when its pricing mode says a transfer offer: registered (a transfer is offered); else not orderable
Gandi GET /domain/check?name= the 1-year price before taxes, in the answer's currency (EUR when it says none) Gandi's status as the reason; premium from the status or the process
Scaleway GET /search-domains, strict none quoted not available; a domain not in the answer not offered
Cloudflare - (NotSupportedException from the registry)

OVH answers its cart's offers to a signed call only: availability needs the account's keys there too.

Registering and renewing

use Omnidns\Model\Contact;

$owner = new Contact('Camille', 'Durand', 'camille@example.org', '+33.612345678', '1 rue de Rivoli', 'Paris', '75001', 'FR');
$order = $registry->registrar('gandi')->register('example.fr', 2, $owner);
echo $order->status, ' ', $order->id, ' ', $order->paymentUrl, "\n";

$registry->registrar('gandi')->renew('example.fr', 1);

Both are purchases. The years go from 1 to 10 at OVH, Gandi and Scaleway (another number is an InvalidArgumentException before any call).

Register Owner Paid Renew
OVHcloud a new cart, assigned to the account, the domain added for P<years>Y, the owner set, checked out a contact the account holds: Contact::handle('<id from /me/contact>'); none: the account's own; a contact in full is an InvalidArgumentException auto_pay: false (the default): the order waits at Order::$paymentUrl, status ordered, awaiting payment; true: paid at once with the preferred means, ordered, paid with the preferred means of payment the service id from /domain/{name}/serviceInfos, then POST /service/{id}/renew
Gandi POST /domain/domains in full (names, e-mail, phone, address, country, type; an empty e-mail or none is an InvalidArgumentException) Gandi bills the account's prepaid balance or means of payment; status Gandi's message, else accepted POST /domain/domains/{name}/renew
Scaleway POST /buy-domains into project_id (required, else InvalidConfigException) a contact Scaleway holds (Contact::handle()) or in full (company is Scaleway's corporate); none is an InvalidArgumentException billed to the organization; status ordered, the task id as id POST /renew-domains

Gandi's dry_run: true sends Dry-Run: 1 on registrations and renewals: Gandi checks the order and makes nothing, and the order's status is checked (dry run). The other providers have no dry run: try an order first in a sandbox (Gandi's endpoint), or not at all.

OVH's order carries its orderId, its price with tax and its url; Gandi's and Scaleway's carry no price.

The account's domains

foreach ($registry->domains('ovh')->domains() as $name) {
    $info = $registry->domains('ovh')->domain($name);
    printf("%s ends %s (%d days)%s\n", $name, $info->expiresAt?->format('Y-m-d'), $info->daysLeft(), $info->autoRenew ? ', renews itself' : '');
}
domains() domain(): end, auto-renewal, statuses, name servers, creation
OVHcloud GET /domain /domain/{name}/serviceInfos: expiration, renew.automatic, the service's status, -, creation
Gandi GET /domain/domains, 100 a page registry_ends_at, autorenew, status, nameservers, registry_created_at
Scaleway GET /registrar/domains, 100 a page, narrowed to project_id when set expired_at, auto_renew_status (enabled or enabling is true), status, the apex zone's ns, -
Cloudflare accounts/{account_id}/registrar/domains expires_at, auto_renew, registry_statuses (split on commas), -, created_at

Name servers

$registry->nameservers('ovh')->setNameservers('example.fr', ['ns1.example.net', 'ns2.example.net']);
$registry->nameservers('ovh')->nameservers('example.fr');   // ["ns1.example.net", "ns2.example.net"]

Host names are lower-cased and their trailing dot removed, both ways.

Read Write
OVHcloud /domain/{name}/nameServer (ids), then each id's host POST /domain/{name}/nameServers/update
Gandi GET /domain/domains/{name}/nameservers PUT the same
Scaleway GET /dns-zones/{name}/nameservers PUT the same
Cloudflare - (a zone's name servers are Cloudflare's own) -

Proposer une modification docs/usage-domains.md

DNS records

DnsInterface: records($zone, $type, $name), add($zone, $record), update($zone, $current, $new), delete($zone, $record). The zone is a domain name, normalized; a record's name is relative to it (Record).

use Omnidns\Model\Record;

$dns = $registry->dns('cloudflare');

$dns->records('example.org');                    // every record of the eight types
$dns->records('example.org', 'MX');              // the MX records
$dns->records('example.org', 'TXT', '_dmarc');   // the TXT records of _dmarc.example.org

$www = $dns->add('example.org', new Record('A', 'www', '192.0.2.10', 300));
$dns->add('example.org', Record::fromValue('MX', '', '10 mx.example.net.'));
$dns->update('example.org', $www, new Record('A', 'www', '192.0.2.11', 300));
$dns->delete('example.org', new Record('A', 'www', '192.0.2.11'));   // found by name, type and content

Finding a record

A provider that names its records (OVH, Cloudflare, Scaleway) gives each one an id, kept in Record::$id: update() and delete() given a record with an id use it directly. Given one without an id, they read the zone's records of that name and type and take the one that is sameAs() it - the same content, a host's case and trailing dot and a text's quotes aside. None found is a ProviderException (... answered no such record for a record of example.org.).

Gandi gives no id: a record there is always found by its name, type and content.

What each provider does

Records Add Update Delete Kept besides
OVHcloud GET /domain/zone/{zone}/record (ids, filtered by fieldType and subDomain), then each id; the apex filtered locally (OVH reads an empty subDomain as no filter) POST .../record, then the zone refreshed PUT .../record/{id} (name, value, TTL; a TTL left null is 0, the zone's default), refreshed; a change of type is a deletion and an addition DELETE .../record/{id}, refreshed -
Gandi (LiveDNS) GET /livedns/domains/{zone}/records, each rrset's values as records, under the rrset's TTL the rrset created (POST), or extended with the value (PUT, its TTL kept unless the record gives one) the value replaced in its rrset (PUT); a change of name or type is a deletion and an addition the value removed (PUT), or the rrset deleted with its last value -
Cloudflare dns_records, 100 a page, filtered by type and full name; TTL 1 ("auto") read as null addRecord(), not proxied unless options['proxied']; SRV and CAA as Cloudflare's structured data updateRecordDetails(); TTL null sent as 1; proxied as the new record says, else as it was deleteRecord() by id options['proxied']
Scaleway GET /dns-zones/{zone}/records, 100 a page, filtered by type and name the zone's PATCH of changes, add set by id delete by id -

The zone's id is asked once per provider object at Cloudflare (Zones::getZoneID()). OVH writes an MX's priority in its value, Gandi too: Record::fromValue() reads them, value() writes them. Scaleway takes a TTL of 3600 when the record gives none, and a priority apart.

SRV and CAA at Cloudflare are written from their zone-file value - 10 5 5060 sip.example.org (priority, weight, port, target), 0 issue "letsencrypt.org" (flags, tag, value) - and an SRV is read back in that form.

A zone served elsewhere

// The domain at OVH, its zone at Cloudflare
$registry->nameservers('registrar')->setNameservers('example.org', ['ada.ns.cloudflare.com', 'bob.ns.cloudflare.com']);
$registry->dns('dns')->add('example.org', new Record('A', '', '192.0.2.10'));

The Cloudflare provider does not read the name servers Cloudflare assigns to a zone: take them from Cloudflare.

Proposer une modification docs/usage-dns.md

Configuration reference

The registry

new Registry(iterable $factories, array $config);
Key Type Required
<name> string - the name the application gives the provider: registrar, dns...
<name>.factory string yes ovh, gandi, cloudflare, scaleway, memory
<name>.options array no ([]) the provider's options, below

Options are taken as given: a required option given as null, '' or [] is reported missing; an optional one given as '' reads as not set. Booleans accept true, "1", "true", "yes", "on" (any case), so an environment variable can be passed as it is.

ovh

Omnidns\Ovh\OvhFactory(?GuzzleHttp\Client $http = null) - ovh/ovh signs every call and sends it with that client, or its own.

Option Type Default Required
application_key string - yes the API application's key
application_secret string - yes its secret
consumer_key string - yes the consumer key the account validated for the rights below
endpoint string ovh-eu no ovh-eu, ovh-ca, ovh-us, kimsufi-eu... (ovh/ovh's names), or a URL
subsidiary string FR no the OVHcloud company carts are opened with: FR, DE, GB, CA...
auto_pay bool false no true: an order is paid at once with the account's preferred means of payment; false: it waits at Order::$paymentUrl

The application is created at https://eu.api.ovh.com/createToken/ with the rights the factory lists: GET/POST on /order/cart*, GET/PUT/POST on /domain*, DELETE on /domain/zone/* (delete(), and an update() that changes a record's type), GET/POST on /service/*.

gandi

Omnidns\Gandi\GandiFactory(HttpClientInterface $http)

Option Type Default Required
token string - yes a personal access token, sent as Authorization: Bearer, with the rights needed: "See and renew domain names", "Manage domain name technical configurations" (LiveDNS), "Register domain names" for a registration
sharing_id string none no the organization billed and owning, when it is not the token's own; added to every /domain/ call
endpoint string https://api.gandi.net/v5 no Gandi's sandbox: https://api.sandbox.gandi.net/v5
dry_run bool false no true: a registration or a renewal is checked by Gandi and not made (Dry-Run: 1)

cloudflare

Omnidns\Cloudflare\CloudflareFactory(?Cloudflare\API\Adapter\Adapter $adapter = null) - none: cloudflare/sdk's Guzzle adapter on the token.

Option Type Default Required
token string - yes an API token with Zone:Read and DNS:Edit on the zones it manages, and Account Registrar:Read for the Registrar's domains
account_id string none no the account whose Registrar domains are read; none: capabilities()->domains is false and domains() throws
base_uri string https://api.cloudflare.com/client/v4/ no given to the SDK's Guzzle adapter (not used with an adapter of the application's)

scaleway

Omnidns\Scaleway\ScalewayFactory(HttpClientInterface $http)

Option Type Default Required
secret_key string - yes an API key's secret, sent as X-Auth-Token, with DomainsDNSFullAccess (or DomainsDNSReadOnly to read)
project_id string none to register the project domains are bought into; it also narrows the domains listed
endpoint string https://api.scaleway.com/domain/v2beta1 no

memory

Omnidns\Testing\MemoryFactory() (Testing).

Option Type Default Required
domains array<string, string> [] no the domains held and when each ends: ['example.org' => '2027-03-01']
taken list<string> [] no the domains held by someone else
price Model\Money none no a year's price: availability's price and renewal, and each order's (times the years)

Proposer une modification docs/configuration.md

Errors

Every exception Omnidns throws implements Omnidns\Exception\OmnidnsException (a \Throwable):

Exception Extends When
InvalidConfigException \InvalidArgumentException a provider not configured, a factory not installed, a required option missing (The "scaleway" provider needs: secret_key.), an option a call needs (The "scaleway" provider needs a project_id to register a domain., Cloudflare's account_id), a Money with a bad currency or amount
InvalidArgumentException \InvalidArgumentException a value no provider takes, before any call: a malformed domain name, a record type outside Record::TYPES, a duration outside 1-10 years, an owner a provider cannot take (none at Scaleway, not in full at Gandi, not a handle at OVH)
NotSupportedException \LogicException the registry's typed getter asked of a provider without that contract, before any call
ProviderException \RuntimeException the provider answered, and not with what was asked: an error status, a domain not in the account, a record not found, an order refused, a body that is not JSON
UnreachableException \RuntimeException the provider could not be reached: a network error, a timeout; a copy of the client's exception is the previous one (no secret in it)

Messages, as the providers write them (run on 2026-10-26 in the harness):

"exa mple.org" is not a domain name (an internationalized one goes in its xn-- form).
"SPF" is not a record type omnidns handles (A, AAAA, CAA, CNAME, MX, NS, SRV, TXT).
A domain is registered or renewed for 1 to 10 years, not 11.
Gandi needs the owner in full to register a domain (Contact: names, e-mail, phone, address, country, type).
Gandi answered 404 (The resource could not be found.) for the domain example.org.
Gandi could not be reached for the availability of example.org: Could not resolve host
memory answered no such record for a record of example.org.

No secret in a failure

An UnreachableException keeps the request's secrets out. The HTTP client's message quotes the URL called: its key parameters, a password in its user part and every secret of the request's options (Gandi's and Cloudflare's bearer token, Scaleway's X-Auth-Token, OVH's application and consumer keys and signature) are masked (api_key=***, Http\Redaction::text()). The previous exception is a copy of the client's (Redaction::copy(): the same class and code, that message, without the request a Guzzle exception carried), and no trace of the chain keeps its arguments, where the query and headers stood: a log that writes the whole chain, arguments included (zend.exception_ignore_args off), holds no key. Nor does an error answered (a refused key, nothing there, a server error, a body that is not JSON): every exception Json::request() throws leaves its trace's arguments out.

ProviderException's code

Gandi and Scaleway (through Http\Json) and Cloudflare (when Guzzle has the response) give the HTTP status as the exception's code: $e->getCode() is 404 for a domain not in the account, 401 for a key refused, 429 for a rate limit. OVH's carries the status in its message only (code 0). Each message keeps the provider's own words in brackets when it gave them: Gandi's and Scaleway's message, Cloudflare's errors[0].message, OVH's message.

What to catch where

Where Catch
a domain search InvalidArgumentException: the name typed is not a domain; ProviderException, UnreachableException: "not available now"
an order the same; a ProviderException after register() may mean the order was made and the answer lost - check domains() before trying again
a record change ProviderException with "no such record": the zone changed since it was read
building the registry, a deploy InvalidConfigException: a configuration error, to fix
a list over every provider check the contract first (instanceof, capabilities()); NotSupportedException is a programming error

Proposer une modification docs/errors.md

Testing

The in-memory provider

Omnidns\Testing\MemoryProvider is a registrar and a DNS host in memory, every contract answered: for tests, demos and an application's fixtures. Its factory, MemoryFactory (name memory), takes the domains held, those held by someone else, and a year's price (options):

use Omnidns\Model\Money;
use Omnidns\Model\Record;
use Omnidns\Registry;
use Omnidns\Testing\MemoryFactory;

$registry = new Registry([new MemoryFactory()], [
    'registrar' => ['factory' => 'memory', 'options' => [
        'domains' => ['example.org' => '2027-03-01'],
        'taken' => ['example.com'],
        'price' => Money::fromDecimal('8.39', 'EUR'),
    ]],
]);

$registry->availability('registrar')->check('example.com')->reason;      // "registered"
$registry->registrar('registrar')->renew('example.org', 2);               // Order: renew, done, 16.78 EUR
$registry->domains('registrar')->domain('example.org')->expiresAt;        // 2029-03-01

$dns = $registry->dns('registrar');
$dns->add('example.org', new Record('A', 'www', '192.0.2.10'));
$dns->update('example.org', new Record('A', 'www', '192.0.2.10'), new Record('A', 'www', '192.0.2.11', 300));

How it behaves:

  • check(): free unless held or taken; priced (first year and renewal) with price when free; reason registered otherwise.
  • register(): a domain not free is a ProviderException; a free one is held from 2026-01-01 (the provider's fixed "now") for the years asked, status ok, auto-renewing, served by ns1.memory.test and ns2.memory.test, its zone empty; the order done, its id order-<n>, its price price times the years.
  • renew(): the end moved by the years; a domain not held is a ProviderException.
  • setNameservers(): kept, lower case, no trailing dot.
  • DNS: a zone exists for each domain held (else ProviderException not served here); records get ids 1, 2... (one counter shared with the orders); update() and delete() find the record with sameAs().

The application keeps the name it uses (registrar); only the factory changes.

A provider on recorded answers

Gandi's and Scaleway's factories take a MockHttpClient:

use Omnidns\Exception\ProviderException;
use Omnidns\Gandi\GandiFactory;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

$gandi = (new GandiFactory(new MockHttpClient(new MockResponse(
    '{"code":404,"message":"The resource could not be found.","object":"HTTPNotFound","cause":"Not Found"}',
    ['http_code' => 404],
))))->create(['token' => 'test']);

try {
    $gandi->domain('example.org');
} catch (ProviderException $e) {
    $e->getCode();   // 404
}

OVH's factory takes a GuzzleHttp\Client - one built on a Guzzle MockHandler answers as recorded - and Cloudflare's a Cloudflare\API\Adapter\Adapter: its tests use a recording adapter of their own (Tests/RecordingAdapter.php), which an application may copy. The providers' tests route the answers by method and path (Tests/Router.php in Gandi and Scaleway).

Live checks

None: every provider needs an account and a key, OVH's availability included. A first use against a real account is the check still owed (Verified, and not); Gandi's sandbox (endpoint: https://api.sandbox.gandi.net/v5) and dry_run are the safe places to start.

Proposer une modification docs/testing.md

The Docker harness

docker/ composes this core and every provider (docker/harness/plugins.php) into one installation, PHP 8.4, in the image omnisense/omnidns-harness. docker/harness/setup.php writes the harness's composer.json and phpunit.xml into a volume, and the entrypoint installs them when they change.

cd docker
docker compose run --rm omnidns test       # every package's tests, no network (the live group left out)
docker compose run --rm omnidns bare       # the bare script: plain PHP, and what PHP loaded
docker compose run --rm omnidns phpstan    # PHPStan at the highest level, each package
docker compose run --rm -v "$PWD/scratch:/scratch" omnidns php /scratch/x.php   # a script of your own (autoloader: /harness/vendor/autoload.php)
docker compose down -v                     # the harness's volume gone

With no command the entrypoint runs test; live runs the live group (no provider has one: none can be read without an account); composer, sh and php run as they are; anything else exits 2 with the list. The service uses Docker's default bridge (network_mode: bridge).

Providers come from GitHub (branch 1.x) unless OMNIDNS_PLUGINS (in docker/.env) points at a directory holding their checkouts - ../.. when this core sits in the workspace beside them, as docker/.env sets it.

Bare

docker/harness/bin/bare uses Omnidns in plain PHP - no bundle, no container, no framework: a registry with the in-memory provider; a domain checked, one taken, one registered for two years and renewed, its name servers changed, its zone written (an A, an MX, a TXT; the A updated, the TXT deleted); a provider whose factory is not installed. It prints what happened and every class and file PHP loaded, as JSON; run on 2026-10-26, without the loaded part:

"free": {"domain": "omnidns-bare.test", "available": true, "price": {"cents": 839, "currency": "EUR", "decimal": "8.39"}, ...},
"taken": false,
"order": {"domain": "omnidns-bare.test", "action": "register", "status": "done", "id": "order-1", "price": {"cents": 1678, ...}, "years": 2},
"domains": ["glitchr.test", "omnidns-bare.test"],
"expires": "2029-01-01",
"nameservers": ["ns1.example.net", "ns2.example.net"],
"records": [["A", "omnidns-bare.test", "192.0.2.2", 300], ["MX", "omnidns-bare.test", "10 mx.example.net.", null]],
"missing": "No \"missing\" factory for the \"nowhere\" DNS provider; installed: memory."

Tests/BareTest.php runs it in a process of its own and checks that nothing outside the package was loaded but Symfony's HTTP client contracts. Each provider's BareTest loads every one of its classes with its required packages alone.

Proposer une modification docs/harness.md

Writing a provider

A provider is a package omnidns/<name> with a factory and a provider class:

  • the factory extends Omnidns\ProviderFactory: populate() names it (omnidns.factory_name), lists its required options (omnidns.required_options) and the defaults of the others; build() returns the provider. The HTTP client (or the official client's adapter) comes through the factory's constructor, never created inside unless none is given.
  • before writing a client, look for a maintained framework-free one and wrap it (OVH: ovh/ovh; Cloudflare: cloudflare/sdk); say in the README what was found.
  • the provider implements the contracts it can answer - AvailabilityInterface, RegistrarInterface, DomainsInterface, NameserversInterface, DnsInterface - and says so in capabilities(), with the record types its DNS takes and the terms that apply.
  • names are normalized with DomainName::normalize(); records hold a name relative to the zone (Record::relative() for a provider that gives full names), the MX priority apart (Record::fromValue() / value() for a provider that writes it in the value); a record type outside Record::TYPES is left out of records().
  • update() and delete() take a record with or without an id: without one, find it with Record::findIn() among records($zone, $type, $name).
  • every call through Http\Json::request() (or the wrapped client's errors turned into ProviderException / UnreachableException).
  • tests run in the core's harness (docker/harness/plugins.php lists the providers), on recorded answers with each request checked, with a BareTest; a LiveTest (group live) only where the provider can be read without an account.

What the four providers keep to

  • Every domain and zone given goes through DomainName::normalize() before any call.
  • A duration outside 1 to 10 years, and an owner the provider cannot take, are refused before any call (InvalidArgumentException).
  • update() and delete() given a record without an id read the zone's records of that name and type and take the one that is sameAs(); none found is a ProviderException naming the zone.
  • A change the provider cannot make in place (a record's type at OVH, a record's name or type in a Gandi rrset) is a deletion and an addition.
  • Host names (name servers, records' targets compared) are lower-cased, their trailing dot removed where omnidns returns them.
  • An order costs money: a provider that can check one without making it says so (Gandi's dry_run), and its README says how an order is paid.

Registering it

  • Plain PHP: give the factory to the Registry.
  • The harness: a line in docker/harness/plugins.php (slug and tests namespace); the provider's checkout beside the core, or its repository at github.com/glitchr-omnisense/omnidns-<slug>.

Proposer une modification docs/providers.md

Verified, and not

What the tests prove

Run on 2026-10-26 in the harness, every package from its checkout, PHP 8.4, no network: 68 tests, 222 assertions, green.

  • The core: the models (names normalized, records relative to their zone, an MX's priority apart and back, the same record by id or by content, days left); the registry; the in-memory provider through every contract; in plain PHP, a registry used loading nothing but omnidns and Symfony's HTTP client contracts (BareTest); the installation page's script run as written (DocsTest).
  • Each provider on recorded answers, every request checked (path, method, credentials - OVH's signature computed as OVH documents it -, body, paging, errors): OVH's availability (free, priced, taken with a transfer offered, not orderable, one cart kept), a registration as a cart checked out with its owner, a renewal through the services API, its domains, name servers and records; Gandi's free and taken domains, a registration with its owner, Dry-Run and sharing_id, a renewal, rrsets created, extended, changed, shrunk and deleted; Cloudflare's records page after page, filtered, an addition (MX, CAA as data), an update keeping the proxy, a deletion by id, the Registrar's domains; Scaleway's availability, a registration with a held contact and with one in full, the project required, a renewal, the records changed through the zone's PATCH; each provider's classes loading with its requirements alone.

The examples of this manual (records and names, the in-memory provider, the error messages) were run in the harness on 2026-10-26.

What is not verified

  • None of the four providers was run against a live account. Every one needs an account and a key, OVH's availability included (its order cart answers "You must login first" to an anonymous question, checked 2026-10-24 per the README). The recorded answers are written from each API's reference, not captured: a first use against a real account is the check still owed, before any order costs money. OVH's renewal call (/service/{id}/renew) is the least sure of the shapes. Scaleway's API is a beta version (v2beta1): its shapes may change.
  • The Symfony and Laravel pages were written from the classes' constructors, not run in an application.
  • The known defects found while writing this manual (Cloudflare's domains without account_id, OVH's listed rights) are fixed since: the registry's getters ask the capabilities (with a test), OVH's rights name DELETE on /domain/zone/* (a list, no test).

Responsibility

omnisense/omnidns and its omnidns/* providers are unofficial clients, affiliated with none of the services they reach. The MIT licence covers the code only and grants no right to any service; whoever runs them complies with each service's terms (named in each provider's README and in Capabilities::$terms), pays for the orders made, and obtains what they require.

Proposer une modification docs/verified.md