kasperhartwich / quickdns
QuickDNS library
Requires
- php: ^8.2
- ext-curl: *
- guzzlehttp/guzzle: ^7.0|^8.0
- symfony/dom-crawler: ^7.4.12|^8.0.12
Requires (Dev)
- orchestra/testbench: ^9.17|^10.0|^11.0
- phpunit/phpunit: ^10.5|^11.5.3|^12.0
- symfony/config: ^6.4|^7.0|^8.0
- symfony/dependency-injection: ^6.4|^7.0|^8.0
- symfony/http-kernel: ^6.4|^7.0|^8.0
Suggests
- illuminate/support: For the Laravel service provider and QuickDns facade (Laravel 11 or later)
- symfony/http-kernel: For the Symfony bundle (Symfony 6.4 or later)
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-23 13:11:26 UTC
README
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.2 or later
Installation
composer require kasperhartwich/quickdns
Usage
Creating the client logs in right away. Wrong credentials throw QuickDns\Exceptions\LoginFailed.
use QuickDns\QuickDns; $quickDns = new QuickDns('my@email.example', 'password');
To log in on the first request instead, for example when the client is built in a service
container long before it is used, use QuickDns::lazy(). It logs in once per instance, and wrong
credentials throw LoginFailed from that first request:
$quickDns = QuickDns::lazy('my@email.example', 'password');
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() sets 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();
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'); $template->addZone($zone); $group->addZone($zone); $template->removeZone($zone); $group->removeZone($zone);
Template and Group also have create() and delete(), just like Zone. QuickDNS does not
answer with a new group's id, so fetch a group with getGroup() after creating it.
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 implements QuickDns\Exceptions\QuickDnsException:
| Exception | When | Extends |
|---|---|---|
LoginFailed |
Wrong email or password | InvalidArgumentException |
CommandFailed |
QuickDNS rejected a command. The message is QuickDNS' own, in Danish, e.g. Zonen eksisterer allerede |
InvalidArgumentException |
NotFound |
getZone(), getTemplate() or getGroup() found nothing |
UnexpectedValueException |
InvalidRecord |
A record QuickDNS would reject, caught before sending | InvalidArgumentException |
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 | InvalidArgumentException |
UnrecognisedPage |
QuickDNS answered with something unexpected, e.g. a logged-out page | UnexpectedValueException |
use QuickDns\Exceptions\CommandFailed; try { (new Zone($quickDns, 'example.dk'))->create(); } catch (CommandFailed $e) { echo 'QuickDNS said: ', $e->getMessage(), 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.