samsmithcodes / laravel-cloudflare-doh-json
A Laravel package to query DNS records from Cloudflare's DOH JSON endpoint.
Package info
gitlab.samsmith.codes/samsmithcodes/laravel-cloudflare-doh-json
pkg:composer/samsmithcodes/laravel-cloudflare-doh-json
Requires
- php: ^8.3
- illuminate/contracts: ^13.0
- illuminate/http: ^13.0
- illuminate/support: ^13.0
- illuminate/validation: ^13.0
Requires (Dev)
- larastan/larastan: ^3.9
- laravel/pao: ^1.0
- laravel/pint: ^1.29
- orchestra/testbench: ^11.0
- pestphp/pest: ^4.6||^5.0
- pestphp/pest-plugin-laravel: ^4.1||^5.0
- pestphp/pest-plugin-type-coverage: ^4.0||^5.0
- phpstan/extension-installer: ^1.4
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Laravel Cloudflare DOH JSON
Look up DNS records from Laravel using Cloudflare's DNS over HTTPS JSON API.
The package sends the lookup, gets Cloudflare's JSON, and turns each record into a class for its type, such as MxRecord or TxtRecord, with its data split into properties.
use SamSmithCodes\CloudflareDohJson\Facades\CloudflareDohJson;
foreach (CloudflareDohJson::mx('gmail.com') as $record) {
echo "{$record->priority} {$record->exchange}"; // 5 gmail-smtp-in.l.google.com
}
Installation
You can install the package via Composer:
composer require samsmithcodes/laravel-cloudflare-doh-json
Requires PHP 8.3+ and Laravel 13. The service provider and the CloudflareDohJson facade are registered automatically.
You may publish the configuration file:
php artisan vendor:publish --tag="cloudflare-doh-json"
Usage
There's a method for each record type, which returns a collection of that type's record class.
use SamSmithCodes\CloudflareDohJson\Facades\CloudflareDohJson;
$records = CloudflareDohJson::a('example.com');
$records->first()->address; // "93.184.216.34"
$records->pluck('address'); // ["93.184.216.34", ...]
$records->first()->ttl; // 300
CloudflareDohJson::txt('_dmarc.google.com')->first()->value; // "v=DMARC1; p=reject; ..."
CloudflareDohJson::mx('gmail.com')->sortBy('priority'); // lowest priority first
To choose the type at runtime, pass a RecordType to records():
use SamSmithCodes\CloudflareDohJson\Enums\RecordType;
CloudflareDohJson::records('example.com', RecordType::MX);
reverse() finds the hostname of an IPv4 or IPv6 address:
CloudflareDohJson::reverse('1.1.1.1')->first()->hostname; // "one.one.one.one"
If you'd rather not use the facade, inject SamSmithCodes\CloudflareDohJson\CloudflareDohJson instead. It has the same methods.
A few things to know:
- No records returns an empty collection, while a domain that doesn't exist throws a
DomainNotFoundException. - Aliases (CNAMEs) are followed by Cloudflare, and only the records of the type you asked for are returned. For example,
www.github.comis an alias forgithub.com, soCloudflareDohJson::a('www.github.com')returnsgithub.com's A records. - Domain names have their trailing dot removed, so you get
mail.example.comrather thanmail.example.com..
Record types
Every record class has these properties:
| Property | Description |
|---|---|
name | The domain name the record belongs to |
ttl | How many seconds the record may be cached for |
data | The record's data exactly as Cloudflare returned it, e.g. "10 mail.example.com." |
Each class also has properties for its type, which are all read only. Records can be converted with toArray() or json_encode().
| Method | Class | Properties |
|---|---|---|
a() | ARecord | address (IPv4) |
aaaa() | AaaaRecord | address (IPv6) |
caa() | CaaRecord | flags, tag (e.g. issue), value (e.g. letsencrypt.org) |
cname() | CnameRecord | target |
dnskey() | DnskeyRecord | flags, protocol, algorithm, publicKey (base64) |
ds() | DsRecord | keyTag, algorithm, digestType, digest (lowercase hex) |
https() | HttpsRecord | priority, target, params (e.g. ['alpn' => 'h3,h2']) |
mx() | MxRecord | priority, exchange |
naptr() | NaptrRecord | order, preference, flags, services, regexp, replacement |
ns() | NsRecord | nameserver |
ptr() | PtrRecord | hostname |
soa() | SoaRecord | primaryNameserver, hostmaster, serial, refresh, retry, expire, minimumTtl |
srv() | SrvRecord | priority, weight, port, target |
sshfp() | SshfpRecord | algorithm, fingerprintType, fingerprint (lowercase hex) |
svcb() | SvcbRecord | priority, target, params |
tlsa() | TlsaRecord | usage, selector, matchingType, certificateData (lowercase hex) |
txt() | TxtRecord | value (the full text), segments (the strings of up to 255 characters the text is stored as) |
Each property has a description in its class under src/Records.
Errors
Every failed lookup throws a LookupException, with a message explaining why, e.g. Cloudflare couldn't be reached or rejected the domain name. When the domain doesn't exist, it's the more specific DomainNotFoundException, which extends LookupException.
use SamSmithCodes\CloudflareDohJson\Exceptions\DomainNotFoundException;
use SamSmithCodes\CloudflareDohJson\Exceptions\LookupException;
try {
$records = CloudflareDohJson::mx($domain);
} catch (DomainNotFoundException $e) {
return back()->withErrors(['domain' => "{$domain} doesn't exist."]);
} catch (LookupException $e) {
report($e);
return back()->withErrors(['domain' => 'The DNS lookup failed, please try again.']);
}
Configuration
The defaults work without any configuration. To change them, set these in your .env file, or publish the config file.
| Key | Env | Default | Description |
|---|---|---|---|
endpoint | CLOUDFLARE_DOH_ENDPOINT | https://cloudflare-dns.com/dns-query | Any endpoint that uses Cloudflare's JSON format |
timeout | CLOUDFLARE_DOH_TIMEOUT | 5 | Seconds to wait for a response |
attempts | CLOUDFLARE_DOH_ATTEMPTS | 2 | How many times a query is tried before throwing an exception |
Testing your application
Queries are sent with Laravel's Http facade, so you can fake them in your tests with Http::fake(). Fake responses use Cloudflare's JSON format, where type is the record type's number, e.g. RecordType::MX->value.
use Illuminate\Support\Facades\Http;
Http::fake([
'cloudflare-dns.com/*' => Http::response([
'Status' => 0,
'Answer' => [
['name' => 'example.com', 'type' => 15, 'TTL' => 300, 'data' => '10 mail.example.com.'],
],
]),
]);
Testing
Run the full test suite with Docker, so you don't need PHP installed locally:
docker run --rm -it \
-v ${PWD}:/app \
registry.samsmith.codes/samsmithcodes/laravel-starter-tools:latest composer test
To run something else, swap composer test at the end of the command for:
- One of the steps below, e.g.
composer lintto fix the code style. - A single test file, e.g.
vendor/bin/pest tests/Feature/CloudflareDohJsonTest.php. - Only the tests with a name matching a filter, e.g.
vendor/bin/pest --filter="attempts".
What composer test runs
The steps are Composer scripts, defined under scripts in composer.json. They run in this order, and stop at the first failure:
| Step | Tool | What it checks | Config |
|---|---|---|---|
composer analyse | PHPStan with Larastan | The code is type safe and has no bugs it can find | phpstan.neon.dist |
composer lint:check | Laravel Pint | The code style is consistent | pint.json |
composer test:types | Pest type coverage | Every parameter, property and return in src has a type | composer.json |
composer test:unit | Pest with Orchestra Testbench | The tests in tests pass, run in parallel | phpunit.xml.dist, tests/Pest.php, tests/TestCase.php |
The tools
Pest runs the tests. It's built on PHPUnit, so it reads PHPUnit's config:
- phpunit.xml.dist lists the test suites (Arch, Feature and Unit) and the
srcdirectory being tested. It runs the tests in a random order, so they can't rely on each other, and fails on warnings or unexpected output. To add a test, create a file ending inTest.phpintests/Featureortests/Unitand it's picked up automatically. - tests/Pest.php runs before the tests, and makes every test use
TestCase. Add any helpers shared between test files here. It also gives each parallel worker its own service provider manifest in the temp directory, as workers rewriting a shared one at the same time can fail on a Docker volume. - tests/ArchTest.php uses Pest's architecture testing to enforce rules across the code, e.g. strict types, every record class extending
Record, and nodd()or insecure functions likemd5().
Orchestra Testbench boots a minimal Laravel app for the tests, so the package is tested just as an app would use it:
- tests/TestCase.php loads the package's service provider in
getPackageProviders(), and callsHttp::preventStrayRequests()before every test, so a test that forgets to fake a response fails rather than querying Cloudflare. A test can change config for itself withconfig([...]). - testbench.yaml and the
workbenchdirectory configure the app used bycomposer serve, for trying the package in a browser. The tests don't use them.
Pest type coverage checks everything in src has a type declared. It's configured by the options on the test:types script in composer.json: --min=100 is the percentage required, and --memory-limit raises PHP's memory limit, which the Docker image's default is too low for.
PHPStan, with the Larastan extension so it understands Laravel's facades and helpers, reads the code without running it to find bugs, e.g. passing the wrong type or calling a method that doesn't exist. phpstan.neon.dist sets:
level, how strict it is, from 0 to 10, currently 7.paths, the directories it checks.ignoreErrors, for false positives, each with a comment saying why. Only add one when the error is wrong, not to hide a real problem.
Laravel Pint checks the code style. pint.json uses Laravel's preset, with a few extra rules, e.g. a blank line before every return. composer lint:check only reports problems, while composer lint fixes them.
The tests
tests/ArchTest.php: architecture rules, e.g. every file declares strict types, and every record class extendsRecord.tests/Feature/CloudflareDohJsonTest.php: lookups against a fake Cloudflare built withHttp::fake(). It covers every record type, CNAMEs being left out, reverse lookups, the errors, and the configured number of attempts. No real requests are made.tests/Feature/ServiceProviderTest.php: the singleton, config, publishing and Facade are registered.tests/Unit/Records/RecordTest.php: every record class parses real Cloudflare data into its properties, including quoted text, escapes and brackets.
Adding a record type
- Add a case to
RecordTypein src/Enums/RecordType.php, with the type's number from the IANA list, and create its class intoRecord(). - Create the record class in
src/Records, copying a similar one, e.g.MxRecord. Split the data with$this->fields()and set each property. - Add a method named after the type to src/CloudflareDohJson.php, and a matching
@methodline to the facade. - Add the type to the dataset in
tests/Feature/CloudflareDohJsonTest.php, and some real data from Cloudflare totests/Unit/Records/RecordTest.php.
License
Laravel Cloudflare DOH JSON is open-sourced software licensed under the MIT license.