Search by

kasperhartwich / quickdns

kasperh

QuickDNS library

Package info

github.com/kasperhartwich/quickdns

pkg:composer/kasperhartwich/quickdns

Statistics

Installs: 420

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

3.0.0 2026-09-26 08:03 UTC

README

Latest Version on Packagist License Tests Code Quality

A PHP client for QuickDNS.dk. Manage zones, templates and groups from code: create and delete zones, attach them to templates and groups, and list what the account holds.

QuickDNS has no public API, so the client logs in with the account's email and password and talks to the same endpoints as the QuickDNS website.

Requirements

  • PHP 8.3 or later

Installation

composer require kasperhartwich/quickdns

Upgrading from 2.x? UPGRADING.md lists what changed in 3.0.

Usage

The client logs in on its first request, not when it is created, so it can be built long before it is used, for example in a service container. Wrong credentials throw QuickDns\Exceptions\LoginFailed from that first request. Call login() to check them right away:

use QuickDns\QuickDns;

$quickDns = new QuickDns('my@email.example', 'password');
$quickDns->login();              // optional: fails here rather than later
$quickDns->isLoggedIn();         // true

QuickDNS ends a login session after a while. When a request is answered with the login page, the client logs in again and sends that request once more, so a long-lived client keeps working. In the middle of an edit it cannot: the pending changes went with the old session, so the edit throws UnrecognisedPage and nothing is saved.

To send the requests through your own Guzzle client (middleware for logging or rate limiting, or a MockHandler in tests), pass it as the third argument. QuickDns keeps the login session cookies itself, so the client needs no cookie jar:

$quickDns = new QuickDns('my@email.example', 'password', new \GuzzleHttp\Client(['handler' => $stack]));

Zones

use QuickDns\Zone;

foreach ($quickDns->getZones() as $zone) {
    echo $zone->domain, ': ', implode(', ', $zone->templates), PHP_EOL;
}

// Create a zone. create() returns it with its id, so it can be used right away.
$zone = (new Zone($quickDns, 'example.dk'))->create();
$zone->delete();

// Or look a zone up by domain.
$quickDns->getZone('example.dk')->delete();

Zones, templates and groups are immutable. create(), rename(), addZone() and removeZone() return the new state as a new object, and the object you called them on keeps describing what it was read as.

Records

foreach ($quickDns->getZone('example.dk')->getRecords() as $record) {
    echo $record->name, ' ', $record->type, ' ', $record->value, PHP_EOL;  // @ MX mx1.example.dk.
}

Each QuickDns\Record has name (as QuickDNS shows it: @, www, *), type, ttl and priority (null when blank), value, row (the record's row on the zone page) and template (the template that added the record, or null). isLocked() is true for template records: they can only be changed on the template itself.

Writing records

edit() runs one QuickDNS edit session: every change is sent as it is made, and the lot is saved when the closure returns.

use QuickDns\RecordSet;
use QuickDns\RecordType;

$zone = $quickDns->getZone('example.dk');

$zone->edit(function (RecordSet $records) {
    $records->add('www', 'A', '192.0.2.10', ttl: 3600);
    $records->add('@', RecordType::MX, 'mx1.example.dk.', ttl: 3600, priority: 10);

    $spf = $records->sole(name: '@', type: RecordType::TXT);
    $records->replace($spf, $spf->withValue('v=spf1 include:_spf.example.dk ~all'));

    $records->remove($records->sole(name: 'old', type: 'A'));
});

Find records with find(name:, type:, value:), where(...) or sole(...), which throws unless exactly one matches. all() returns them in page order, and the set is iterable and countable.

Nothing is saved unless everything works. If the closure throws, or QuickDNS rejects a change, the session is discarded. That is QuickDNS' own behaviour: it saves nothing from a session that holds a rejected record, not even the changes it accepted.

For a single change there are one-shot helpers, each its own session:

$record = $zone->addRecord('www', 'A', '192.0.2.10', ttl: 3600);
$zone->replaceRecord($record, $record->withValue('192.0.2.11'));
$zone->deleteRecord($record);

What QuickDNS accepts, checked against the service:

Types RecordType: A, AAAA, CNAME, MX, NS, PTR, SPF, SRV, TXT
Priority MX and SRV only. Changing the type to another one clears it.
TTL Any number of seconds, or null to inherit. Record::TTLS holds the values QuickDNS' own dropdown offers.
Names and values Printable ASCII, and never ", ' or \. Danish letters are rejected here, although they are fine in a template or group name.
Duplicates QuickDNS accepts them silently, so add() refuses a record the zone already has. Pass allowDuplicates: true to add it anyway.
Template records Cannot be changed or deleted on the zone: RecordLocked.

A record that breaks one of the first four rules throws InvalidRecord before anything is sent. What only QuickDNS can judge throws RecordRejected, which carries its Danish messages in errors(), the rows in rows() and the records in records().

Templates and groups

$template = $quickDns->getTemplate('my-template');
$group = $quickDns->getGroup('my-group');

$zone = $quickDns->getZone('example.dk');
$zone = $template->addZone($zone);      // keeps the zone's other templates
$zone = $group->addZone($zone);

$zone = $template->removeZone($zone);   // takes off only this one
$zone = $group->removeZone($zone);

Each of these reads the zone's current templates or groups from QuickDNS first, because QuickDNS replaces the whole list, and returns the zone as QuickDNS shows it afterwards.

A zone can use several templates, and $zone->templates lists their names. $zone->templates(), $zone->groups(), $template->zones() and $template->groups() read the related objects themselves, and $template->zoneCount says how many zones use a template. To set the whole list at once, by name, id or object:

$quickDns->setTemplates($zone, ['my-template', 'another']);
$quickDns->setTemplates($zone, []);              // removes them all
$quickDns->setGroups($zone, ['my-group']);

Template and Group also have create(), delete() and rename(), just like Zone. QuickDNS does not answer with a new group's id, so a group's create() reads the groups page to find it.

A group's members are the QuickDNS users it is shared with, as QuickDns\Member objects with id, name, email and confirmed (false while an invitation is not accepted).

A template's records

A template holds records of its own, and every zone using it gets them. They read and write exactly like a zone's, and on the zone they show up as locked:

$template = $quickDns->getTemplate('my-template');

foreach ($template->getRecords() as $record) {
    echo $record->name, ' ', $record->type, ' ', $record->value, PHP_EOL;
}

$template->edit(function (RecordSet $records) {
    $records->add('@', RecordType::MX, 'mx1.example.dk.', ttl: 3600, priority: 10);
    $records->add('www', 'A', '192.0.2.10', ttl: 3600);
});

$template->addRecord('mail', 'A', '192.0.2.20', ttl: 3600);
$template = $template->rename('another-name');

A template's records are applied to each zone exactly as they are written. Nothing is rewritten:

  • Names are relative to the zone. @ is the zone's apex, www is www. plus the zone, and * is the wildcard. On a zone, the template row www A 192.0.2.2 shows as www A 192.0.2.2, locked.
  • @ is the only placeholder. As a value it also means the zone's apex, so alias CNAME @ and @ MX @ work on every zone. There is no {domain}, $DOMAIN or similar. On a zone, a value containing {, } or $ is rejected ("indeholder ugyldige tegn").
  • Any other target is literal. A CNAME, MX or NS target outside the zone needs its trailing dot (mail.example.dk.). QuickDNS refuses a CNAME to a bare name such as www.

A template can be saved and still be unusable. The template page checks less than a zone does, so a record the zone would refuse is only caught when the template is applied. Then addZone() and setTemplates() throw CommandFailed ("De valgte skabeloner giver 1 fejl i zonen"). Nothing is applied, and the zone keeps the templates it had. Records added to a template that zones already use show up on those zones straight away.

Example: set up several domains from one template

use QuickDns\QuickDns;
use QuickDns\Zone;

$quickDns = new QuickDns('my@email.example', 'password');
$template = $quickDns->getTemplate('my-template');

foreach (['domain1.dk', 'domain2.dk', 'domain3.dk'] as $domain) {
    $zone = (new Zone($quickDns, $domain))->create();
    $template->addZone($zone);

    echo "{$domain} created and added to {$template->name}", PHP_EOL;
}

Errors

Every exception from QuickDNS extends the abstract QuickDns\Exceptions\QuickDnsException:

Exception When Extends
LoginFailed Wrong email or password QuickDnsException
CommandFailed QuickDNS rejected a command. The message is QuickDNS' own, in Danish, e.g. Zonen eksisterer allerede QuickDnsException
NotFound getZone(), getTemplate() or getGroup() found nothing QuickDnsException
MissingId The zone, template or group has no id yet, so it cannot be changed QuickDnsException
InvalidRecord A record QuickDNS would reject, caught before sending QuickDnsException
RecordLocked The record belongs to a template InvalidRecord
RecordRejected QuickDNS rejected a change, so the edit was discarded CommandFailed
StaleRecord The record was replaced or removed earlier in the same edit QuickDnsException
UnrecognisedPage QuickDNS answered with something unexpected, e.g. a logged-out page QuickDnsException

Using the library wrongly, such as starting an edit inside another, throws LogicException instead: that is a bug to fix, not something QuickDNS said.

CommandFailed also says what failed: function() is the command, status() QuickDNS' status, statusText() its message and fields() the rest of its answer.

use QuickDns\Exceptions\CommandFailed;

try {
    (new Zone($quickDns, 'example.dk'))->create();
} catch (CommandFailed $e) {
    echo $e->function(), ' failed: ', $e->statusText(), PHP_EOL;
}

Laravel

The service provider is discovered automatically. Set the account in .env:

QUICKDNS_EMAIL=my@email.example
QUICKDNS_PASSWORD=password

and use QuickDns\QuickDns from the container (it logs in on first use), or the facade:

use QuickDns\Laravel\Facades\QuickDns;

$zones = QuickDns::getZones();

php artisan vendor:publish --tag=quickdns-config publishes config/quickdns.php, where client can name a container binding of a GuzzleHttp\ClientInterface to send the requests with. In tests, QuickDns::fake() swaps in a FakeQuickDns and returns it.

Symfony

Register the bundle in config/bundles.php:

return [
    // ...
    QuickDns\Symfony\QuickDnsBundle::class => ['all' => true],
];

and configure it in config/packages/quickdns.yaml:

quickdns:
    email: '%env(QUICKDNS_EMAIL)%'
    password: '%env(QUICKDNS_PASSWORD)%'
    # client: my_guzzle_client   # optional service id of a GuzzleHttp\ClientInterface

QuickDns\QuickDns is then autowirable. It logs in on first use.

Testing your code

QuickDns\Testing\FakeQuickDns is an in-memory quickdns.dk. It keeps zones, templates, groups and records and answers with the same pages as QuickDNS, so code that uses this package can be tested without the network:

use QuickDns\Testing\FakeQuickDns;
use QuickDns\Zone;

$fake = new FakeQuickDns();
$fake->addTemplate('standard');
$fake->addZone('existing.dk', templates: ['standard']);
$fake->addRecord('existing.dk', '@', 'MX', 'mx1.example.dk.', 3600, 10);

$quickDns = $fake->quickDns(); // or new QuickDns('test@example.dk', 'secret', $fake->client())

(new Zone($quickDns, 'new.dk'))->create();

$fake->hasZone('new.dk');           // true
$fake->templatesOf('existing.dk');  // ['standard']
$fake->recordsOf('existing.dk');    // the zone's records after your writes
$fake->requests();                  // every request it answered

The fake writes records too, with the same rules, the same locked template rows and the same all-or-nothing saving. $fake->failNextChange('...') makes the next change fail the way QuickDNS would, and $fake->hasPendingChanges('example.dk') shows whether an edit was left open.

Like QuickDNS, every new zone gets four NS records from the template "QuickDNS global". To keep your own middleware, use the fake as the handler: HandlerStack::create($fake).

Testing this package

composer test

runs the offline test suite against recorded QuickDNS pages. No account needed.

The live suite creates and deletes real zones, templates and groups, so run it against a dedicated test account, never one in use:

QUICKDNS_EMAIL=test@example.dk QUICKDNS_PASSWORD=secret composer test:live

License

MIT. See LICENSE.txt.

Contributing

Pull requests are welcome.