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()andrenew()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 atOrder::$paymentUrl; Gandi'sdry_runhas 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
- Installation: requirements, a first script in plain PHP (run as written by the tests).
- Symfony and Laravel: no bridge yet; the registry as a service, by hand.
- Concepts: providers and their contracts, capabilities, factories, the registry, every model.
- Usage: domains (availability, orders, terms, name servers) and DNS records.
- Configuration reference: every provider's options.
- Errors.
- Testing: the in-memory provider, recorded answers.
- The Docker harness.
- Writing a provider.
- Verified, and not.
Propose a change 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/omnidnsrequiressymfony/http-client-contracts(^3.0), interfaces only, and nothing else.omnidns/gandiandomnidns/scalewayrequireomnisense/omnidns(1.x) andsymfony/http-client-contracts: the application gives the HTTP client (symfony/http-client, or any implementation of the contracts) to the factory's constructor.omnidns/ovhrequiresovh/ovh(^3.4, OVHcloud's official client) andguzzlehttp/guzzle(^7.0);ovh/ovhbringsleague/oauth2-client.omnidns/cloudflarerequirescloudflare/sdk(^1.4 or ^2.0) andpsr/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.
Propose a change 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.
Propose a change 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.
Propose a change 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:
createConfig($options): the options in aConfig(anArrayObject), then the factory'spopulate()setsomnidns.factory_name,omnidns.factory_title,omnidns.required_optionsand the defaults of the others (Config::defaults()sets only keys not set yet);- the required options are checked: one missing,
''or[]is anInvalidConfigExceptionlisting every missing key (The "scaleway" provider needs: secret_key.); 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.
Propose a change 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) | - |
Propose a change 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.
Propose a change 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) |
Propose a change 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 |
Propose a change 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) withpricewhen free;reasonregisteredotherwise.register(): a domain not free is aProviderException; a free one is held from 2026-01-01 (the provider's fixed "now") for the years asked, statusok, auto-renewing, served byns1.memory.testandns2.memory.test, its zone empty; the orderdone, its idorder-<n>, its pricepricetimes the years.renew(): the end moved by the years; a domain not held is aProviderException.setNameservers(): kept, lower case, no trailing dot.- DNS: a zone exists for each domain held (else
ProviderExceptionnot served here); records get ids1,2... (one counter shared with the orders);update()anddelete()find the record withsameAs().
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.
Propose a change 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.
Propose a change 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 incapabilities(), 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 outsideRecord::TYPESis left out ofrecords(). update()anddelete()take a record with or without an id: without one, find it withRecord::findIn()amongrecords($zone, $type, $name).- every call through
Http\Json::request()(or the wrapped client's errors turned intoProviderException/UnreachableException). - tests run in the core's harness (
docker/harness/plugins.phplists the providers), on recorded answers with each request checked, with aBareTest; aLiveTest(grouplive) 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()anddelete()given a record without an id read the zone's records of that name and type and take the one that issameAs(); none found is aProviderExceptionnaming 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 atgithub.com/glitchr-omnisense/omnidns-<slug>.
Propose a change 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-Runandsharing_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.