inwx/domrobot
PHP client for the INWX DomRobot API, enabling domain registration, DNS record management, contact handling and nameserver operations through a clean object-oriented interface. Simplifies integrating domain and DNS automation into PHP applications.
inwx/domrobot is a thin, dependency-free wrapper around INWX's JSON-RPC API (https://api.inwx.com/). It gives you domain, nameserver, contact, accounting and domainprereg namespaces, each method invoked through one generic call().
composer require inwx/domrobot
Add credentials to config/services.php (never to a committed file):
'inwx' => [
'endpoint' => env('INWX_ENDPOINT', 'api.inwx.com'), // 'ote.inwx.com' for the test system
'user' => env('INWX_USER'),
'password' => env('INWX_PASSWORD'),
'shared_secret' => env('INWX_SHARED_SECRET'), // TOTP secret, required if 2FA is on
'debug' => env('INWX_DEBUG', false),
],
Bind it once so every request reuses a single logged-in session:
// app/Providers/AppServiceProvider.php
$this->app->singleton(\INWX\Domrobot::class, function () {
$client = new \INWX\Domrobot(config('services.inwx.endpoint'));
$client->login(
config('services.inwx.user'),
config('services.inwx.password'),
config('services.inwx.shared_secret'),
);
return $client;
});
Where to look first: the repository's examples/ folder (one file per API area) and the class itself — it is small enough to read end-to-end in ten minutes. Then bookmark the official API reference at inwx.com/en/help/apidoc, because the PHP client documents almost nothing about parameters or return shapes; the docs are the real contract.
First use case: a domain availability widget on a signup or search page.
$result = app(\INWX\Domrobot::class)->call('domain', 'check', ['domain' => 'example.com']);
if ($result['code'] !== 1000) {
// API-level failure — thrown as a value, not an exception
}
$availability = $result['resData']['domain'][0];
// ['domain' => 'example.com', 'avail' => 1, 'premium' => 0, ...]
Wrap the client, never inject it raw. The class is stateful (it holds a session cookie) and its call() signature is stringly-typed. Put a small service in front of it so controllers and jobs stay testable and you have one place to normalise errors:
class InwxClient
{
public function __construct(private \INWX\Domrobot $client) {}
public function availability(string $domain): DomainAvailability
{
$res = $this->client->call('domain', 'check', ['domain' => $domain]);
$this->guard($res);
$row = $res['resData']['domain'][0] ?? throw new InwxException('Empty check response');
return new DomainAvailability($domain, (bool) $row['avail'], (bool) ($row['premium'] ?? false));
}
private function guard(array $res): void
{
if (($res['code'] ?? 0) !== 1000) {
throw new InwxApiException($res['msg'] ?? 'Unknown INWX error', $res['code'] ?? 0);
}
}
}
Cache availability checks. domain.check is the single most-called endpoint in any storefront integration and it is rate-limited per account. A 60–300 second cache keyed on the punycode form of the domain is almost always correct, and it keeps a busy search box from starving your provisioning jobs of quota.
Cache::remember("inwx:check:{$domain}", 120, fn () => $this->inwx->availability($domain));
Provision in a queued job, not in the checkout request. Registration can be slow, can return a pending state for some TLDs, and can fail for reasons (handle validation, TLD rules) that you want to retry or surface asynchronously.
class RegisterDomain implements ShouldQueue
{
public int $tries = 5;
public array $backoff = [10, 60, 300, 900];
public function handle(InwxClient $inwx): void
{
if ($this->order->inwx_ro_id) {
return; // idempotent: already submitted
}
$res = $inwx->call('domain', 'create', [
'domain' => $this->order->domain,
'renewalMode' => 'AUTORENEW',
'nameservers' => ['ns1.example.com', 'ns2.example.com'],
'contacts' => ['registrant' => $this->order->contact_handle],
]);
$this->order->update(['inwx_ro_id' => $res['resData']['roId']]);
}
}
Persist the roId — it is the only stable handle for polling domain.info, and it is what makes retries safe.
Create contact handles before domains. contact.create returns a contactId; registration needs that ID, and some TLDs (.de, .at) require a locally-resident registrant handle. Store handles on your Customer/BillingAddress model so you create them once, not per order.
Diff DNS records instead of blasting them. nameserver.setRecord replaces the entire record set for a zone. The safe pattern is read → diff → create/update/delete:
$existing = $inwx->call('nameserver', 'info', ['domain' => $zone])['resData']['record'];
$desired = collect($records)->keyBy(fn ($r) => "{$r['name']}|{$r['type']}");
// createRecord / updateRecord / deleteRecord per delta; only then setRecord if you truly need a replace
Keep a dns_records table mirroring what you believe is live, and reconcile on a schedule. That turns DNS management from "fire and pray" into a normal Eloquent CRUD flow with a sync step.
Poll for what webhooks can't tell you. INWX has no callback/push API. Expiry reminders, transfer completions and renewals all come from a scheduled command that walks domain.list and compares exDate to now()->addDays(30). Run it daily, on a queue, and dedupe notifications in the database.
Log every raw response. Add an inwx_calls table (method, params with secrets redacted, code, msg, latency) written from the wrapper. When a registrar integration misbehaves in production — and it will — this table is the difference between a five-minute diagnosis and an afternoon of guessing.
Test with a fake at the wrapper boundary. The library has no injectable HTTP transport, so Http::fake() does nothing and there is no built-in mock. Bind an interface (InwxClientContract) in the container and swap in a fake in tests. Reserve real OTE calls for a nightly, credential-gated integration suite.
Errors come back as data, not exceptions. An INWX call that fails at the API level returns HTTP 200 with code !== 1000 and a human-readable msg. Only transport problems raise. Every single call site needs the code === 1000 guard, or a failed registration will look like a success to your code.
One login per PHP process, and the session does not survive the request. The client stores the session cookie in memory. Do not serialize it into the Laravel session or cache — a new Domrobot instance means a new login(). With php artisan serve, Octane, or per-job instantiation, budget for that login call and be aware that a burst of workers equals a burst of logins against your rate limit. Under Octane, be careful with the singleton: it will be alive across requests, which is good for quota but means a stale session can outlive its TTL — catch an auth error once and re-login rather than failing the user request.
2FA is not optional once enabled. If the account has TOTP turned on, login() fails unless you pass the shared secret as the third argument; the library derives the TAN itself. A "works locally, fails on prod" bug here is almost always a missing INWX_SHARED_SECRET.
Constructor signature drift between major versions. The v2 → v3 jump moved the code into namespaces and changed how the endpoint is passed. Composer constraints that allow both (^2|^3) will compile in one environment and fatal in another. Pin the major, read the changelog on bumps, and prefer passing the endpoint host explicitly over relying on a boolean flag.
debug mode logs raw request payloads. Those payloads contain your username, password and TAN. Never leave INWX_DEBUG=true reachable in production, and scrub the audit table's params before insert.
IP allowlisting is real. INWX lets you restrict API access to specific source IPs. Serverless egress (Vapor, Lambda, ephemeral CI runners) will break intermittently, and the error message is not always obvious. Use a NAT/static egress, or a dedicated account for CI.
nameserver.setRecord is a full replace. Calling it with a partial list silently deletes every record you left out. This is the single most common way people take a customer's site down with this package. Use the granular createRecord/updateRecord/deleteRecord methods.
Response shapes are inconsistent. resData is sometimes a flat object, sometimes { "domain": [ {...} ] }, sometimes { "record": [...] } depending on the method. There is no schema in the client and no typed DTOs. Defensive array access plus a per-method normaliser in your wrapper pays for itself within a week.
Not everything is instant. domain.create, transfers and TLD switches can return a pending status. Poll domain.info by roId — not by domain name — and treat the order as in-flight until the status settles.
Watch the types. Prices come back as strings in EUR; TTLs and priorities are integers; dates are YYYY-MM-DD HH:MM:SS strings that need explicit parsing (they are not ISO-8601, and Carbon::parse on them is fine but Carbon::createFromFormat is safer if you care about the server's zone). Cast at the boundary, not in Blade.
Internationalised domains need punycode. Send the xn-- form to domain.check and to registration, but show the Unicode form in the UI. Laravel's Str helpers don't cover this; use idn_to_ascii() and check the intl extension is installed.
Premium domains will blow up your margins. domain.check returns a premium flag and a price, and they are wildly higher than your list price. Gate premium registrations behind an explicit confirmation step or you will sell a €4,000 domain for €12.
Low bus factor. 46 stars, no dependents, effectively a single-maintainer, low-activity project. That isn't a problem — the INWX API is stable and the wrapper is a few hundred lines — but it means you own the code. For anything mission-critical, keep a App\Support\Inwx wrapper thin enough that you could swap it for a hand-rolled Http::post('https://api.inwx.com/', ...) client without touching a single caller. That escape hatch is worth designing in from day one.
How can I help you explore Laravel packages today?