metaregistrar/php-epp-client
A PHP client for EPP (Extensible Provisioning Protocol) that lets you connect to domain registries to check, register, renew, transfer, and manage domains, contacts, and DNSSEC data through a simple object-oriented API.
Install with Composer:
composer require metaregistrar/php-epp-client
Look here first, in this order:
config/*.ini — the package ships ready-made connection profiles for a number of registries (SIDN, Nominet, RGP/IIS, Key-Systems, etc.). Copy one and edit credentials rather than hand-building a connection. This is the fastest way to understand which knobs exist (hostname, port, username, password, certificate, privatekey, capath, timeout, ssl/tls, logfile, debuglog).src/Metaregistrar/EPP/ — flat class set: eppConnection, eppRequest/eppResponse and their per-command subclasses, plus the value objects eppDomain, eppContact, eppHost.tests/ — the most reliable documentation of real request/response flows for your installed version.A minimal first use case — check availability, then register:
use Metaregistrar\EPP\eppConnection;
use Metaregistrar\EPP\eppDomain;
use Metaregistrar\EPP\eppCheckRequest;
use Metaregistrar\EPP\eppCreateRequest;
use Metaregistrar\EPP\eppContactHandle;
$conn = new eppConnection(__DIR__ . '/config/mijn-registry.ini');
$conn->setLogFile(__DIR__ . '/storage/logs/epp.log');
try {
if (!$conn->login()) {
throw new RuntimeException('EPP login failed');
}
$check = new eppCheckRequest(new eppDomain('example.nl'));
$conn->request($check);
$checked = $conn->getResponse()->getCheckedDomains(); // [['domain'=>..., 'available'=>bool, 'reason'=>...]]
if ($checked[0]['available']) {
$domain = new eppDomain('example.nl');
$domain->setRegistrant(new eppContactHandle('REG-0001'));
$domain->addContact(new eppContactHandle('TECH-0001'), 'tech');
$domain->addPeriod(1, 'y');
$domain->addNs('ns1.example.nl');
$domain->addNs('ns2.example.nl');
$domain->setAuthorisationInfo(bin2hex(random_bytes(8)));
$conn->request(new eppCreateRequest($domain));
$response = $conn->getResponse();
if (!$response->success()) {
throw new RuntimeException($response->getResultCode() . ': ' . $response->getResultMessage());
}
}
} finally {
$conn->logout();
}
That loop — login → one or more request() calls → inspect getResponse() → logout — is the whole API. Everything else is choosing the right request object.
Treat the connection as a long-lived, serialized resource. An eppConnection is one TLS socket with one authenticated session. Don't open one per HTTP request in a busy application; either reuse it across a queue worker's lifetime, or keep a small pool keyed by registry. Wrap it so callers never touch login()/logout() directly:
final class RegistrySession
{
public function __construct(private eppConnection $conn) {}
public function __call(string $method, array $args) { /* not needed */ }
public function execute(eppRequest $request): eppResponse
{
if (!$this->conn->request($request)) {
throw new EppTransportException('request failed');
}
return $this->conn->getResponse();
}
public function assertSuccess(eppResponse $r): eppResponse
{
if (!$r->success()) {
throw new EppCommandException($r->getResultMessage(), $r->getResultCode());
}
return $r;
}
}
Keep value objects and request objects separate. eppDomain, eppContact, eppHost are plain data holders; epp*Request classes translate them into XML. Build the domain once, reuse it for create/update/renew:
$patch = new eppDomain('example.nl');
$patch->addStatus('clientHold');
$conn->request(new eppUpdateDomainRequest($domain, null, null, $patch));
For NS/contact changes on update, build three eppDomain objects ($add, $remove, $update) and pass them positionally — only the non-null ones are serialized.
Lifecycle commands all follow the same shape:
new eppInfoDomainRequest($domain); // + setHosts(true) for NS list
new eppRenewRequest($domain, $currentExpiry, 1); // currentExpiry is a DateTime/UTC string
new eppDeleteRequest($domain);
new eppTransferRequest(eppTransferRequest::OPERATION_REQUEST, $domain, $authCode);
// OPERATION_QUERY / OPERATION_APPROVE / OPERATION_CANCEL for the rest
Contacts and hosts are first-class too — eppCreateRequest(new eppContact(...)) with postal info, voice, email, and setAuthInfo(); hosts via eppHost with addAddress(), including glue records.
Poll the message queue. Registries push transfer/expiry notifications; you must <poll> and then <poll op="ack"> with the returned msgID, otherwise the queue wedges. Run it on a cron/worker loop:
$conn->request(new eppPollRequest(eppPollRequest::POLL_REQ));
$response = $conn->getResponse();
while ($response->getResultCode() === 1300) {
$msgId = $response->getPollResId();
// handle the message, then ack it
$conn->request(new eppPollRequest(eppPollRequest::POLL_ACK, $msgId));
$conn->request(new eppPollRequest(eppPollRequest::POLL_REQ));
$response = $conn->getResponse();
}
Turn on XML logging in every environment except the noisiest production path. The raw <epp>/</epp> payload in the log file is what registry support will ask for, and it is the only practical way to debug a rejected create.
request() semantics vary by version. Older releases return bool and you must call getResponse(); some paths return the response object. Don't assume — check the return of the version you pinned, and never call getResponse() without first checking the boolean, or you'll silently reuse the previous response.1001 is not a failure. "Command completed successfully; action pending" means the transfer/create is queued. Treat success() as "accepted", then reconcile asynchronously via info or poll messages.logout()) burns a slot until timeout. Always logout() in a finally, and set a sane timeout in the ini.capath, a missing intermediate, or a PEM containing both key and cert in the wrong order produces opaque TLS failures that look like login failures. Test with openssl s_client against the registry endpoint first.Y-m-d\TH:i:s.v\Z style timestamps; passing a local-time DateTime gives you an off-by-hours expiry or an outright 2005 parameter error. Convert with ->setTimezone(new DateTimeZone('UTC')) before handing it to the request object.true vs 1, and relative certificate paths resolved against the working directory all cause confusing failures. Prefer absolute paths, and prefer constructing the connection programmatically for anything non-trivial.<extension> XML or injecting into getExtension() output re-introduces escaping bugs. Use the value objects and let them escape.addons directory of your installed version before writing a generic call that one registry will reject.vendor/. If you must customize, prefer subclassing epp*Request and overriding the XML builder, then keep your subclasses in app/Epp/.^5 can change response accessors under you without a major-version signal.How can I help you explore Laravel packages today?