hostfog / toolkit
Zero-dependency toolkit for web developers and server admins: domain normalisation, DNS-over-HTTPS lookups, SSL certificate checks, Nginx config builder, health checks and utilities.
Requires
- php: >=8.1
- ext-curl: *
- ext-json: *
- ext-openssl: *
Requires (Dev)
- phpunit/phpunit: ^10.5
Suggests
- ext-intl: Uses idn_to_ascii() for IDN conversion; a built-in punycode encoder is used otherwise.
- ext-mbstring: Enables Unicode-aware lowercasing of internationalised domain names.
Provides
None
Conflicts
None
Replaces
None
README
HostFog Toolkit for PHP
Zero-dependency utilities every web developer and server admin needs:
domain normalisation, DNS-over-HTTPS lookups, SSL certificate checks, an Nginx config builder, health checks and handy helpers.
Install
composer require hostfog/toolkit
Quick start
<?php require 'vendor/autoload.php'; use HostFog\Toolkit\Dns; use HostFog\Toolkit\Domain; use HostFog\Toolkit\Ssl; $domain = Domain::normalize('https://www.Example.com/shop?q=1'); // "www.example.com" $records = (new Dns())->lookup($domain, 'A'); $cert = Ssl::certificate($domain); echo "{$domain} resolves to {$records[0]['value']} and its certificate expires in {$cert->daysLeft} days\n";
Contents
Domain
Domain::normalize(string $input): string
Trims, lowercases, strips the scheme, userinfo, port, path, query, fragment and trailing dot, and converts IDNs to punycode. Throws InvalidDomainException if the result is not a valid domain.
use HostFog\Toolkit\Domain; Domain::normalize(' Example.COM '); // "example.com" Domain::normalize('http://user:pass@shop.example.co.uk:8080/'); // "shop.example.co.uk" Domain::normalize('https://Bücher.de/'); // "xn--bcher-kva.de" Domain::normalize('localhost'); // throws InvalidDomainException
IDN conversion uses idn_to_ascii() when ext-intl is loaded and a built-in RFC 3492 punycode encoder otherwise, so it works on any PHP install.
Domain::isValid(string $input): bool
Validates without stripping schemes or paths (case and IDN characters are accepted). Rules: at most 253 characters, at least two labels, each label 1 to 63 characters of a-z 0-9 - that doesn't start or end with -, and a TLD that isn't all digits.
Domain::isValid('sub.example.co.uk'); // true Domain::isValid('bücher.de'); // true Domain::isValid('https://example.com'); // false Domain::isValid('example.123'); // false
DNS over HTTPS
Lookups go through the Cloudflare or Google JSON DoH APIs, so results are identical on every machine regardless of the system resolver. Supported types: A, AAAA, CNAME, MX, TXT, NS, CAA. Requests time out after 5 seconds.
new Dns(string $resolver = 'cloudflare', ?callable $transport = null)
use HostFog\Toolkit\Dns; $dns = new Dns(); // Cloudflare $dns = new Dns('google'); // Google $dns = new Dns('https://doh.example.net/dns-query'); // any DoH JSON endpoint // Custom transport, e.g. for tests: (string $url, array $headers): array{status: int, body: string} $dns = new Dns('cloudflare', fn (string $url, array $headers) => ['status' => 200, 'body' => '{"Status":0}']);
$dns->lookup(string $domain, string $type = 'A'): array
Returns a list of records shaped like ['type', 'name', 'value', 'ttl', 'priority']. priority is only set for MX. NXDOMAIN gives []. CNAME chains inside an A answer are skipped, TXT chunks are joined and trailing dots are stripped.
$dns->lookup('example.com', 'MX'); // [ // ['type' => 'MX', 'name' => 'example.com', 'value' => 'mail.example.com', 'ttl' => 300, 'priority' => 10], // ['type' => 'MX', 'name' => 'example.com', 'value' => 'backup-mail.example.com', 'ttl' => 300, 'priority' => 20], // ] $dns->lookup('example.com', 'TXT')[0]['value']; // "v=spf1 include:_spf.hostfog.io -all"
$dns->pointsTo(string $domain, string $ip): bool
Checks A records for IPv4 addresses (anything with a .) and AAAA records otherwise.
$dns->pointsTo('example.com', '93.184.216.34'); // true $dns->pointsTo('example.com', '2606:2800:220:1:248:1893:25c8:1946'); // AAAA lookup
$dns->propagation(string $domain, string $type = 'A'): array
Queries both Cloudflare and Google and compares the sorted values.
$dns->propagation('example.com'); // [ // 'results' => ['cloudflare' => ['93.184.216.34'], 'google' => ['93.184.216.34']], // 'consistent' => true, // ]
SSL certificates
Ssl::certificate(string $host, int $port = 443, float $timeout = 10.0, ?string $caFile = null, ?string $connectHost = null): CertificateInfo
Connects over TLS with SNI set to $host and full certificate verification. Throws SslException with the underlying reason if the connection or handshake fails or the certificate isn't trusted.
use HostFog\Toolkit\Ssl; $cert = Ssl::certificate('hostfog.com'); // Trust a private CA instead of the system store: $cert = Ssl::certificate('panel.internal.example', 2087, 5.0, '/etc/ssl/my-ca.pem'); // Check a new server before DNS points at it: connect to the IP but verify the host name. $cert = Ssl::certificate('example.com', 443, 10.0, null, '203.0.113.10');
$connectHost is a PHP addition to the shared spec (the JS library has the same option). It changes only where the TCP connection goes; SNI and verification still use $host.
Ssl::parsePem(string $pem): CertificateInfo
Parses a PEM certificate offline.
$cert = Ssl::parsePem(file_get_contents('/etc/ssl/example.com/fullchain.pem'));
CertificateInfo
| Property | Type | Example |
|---|---|---|
subject |
?string |
"test.hostfog.dev" (the CN) |
issuer |
?string |
"R11" (issuer CN, or O when there's no CN) |
sans |
list<string> |
["test.hostfog.dev", "*.hostfog.dev"] |
validFrom |
string |
"2026-10-16T08:10:53Z" |
validTo |
string |
"2036-10-13T08:10:53Z" |
daysLeft |
int |
3649 (negative once expired) |
expired |
bool |
false |
serialNumber |
string |
"1A2B3C4D" (uppercase hex) |
$cert->coversHost('a.hostfog.dev'); // true (*.hostfog.dev) $cert->coversHost('a.b.hostfog.dev'); // false (wildcards match one label) $cert->coversHost('hostfog.dev'); // false if ($cert->expiresWithin(14)) { echo "Renew {$cert->subject} now\n"; } $cert->toArray(); // every field as an associative array
Nginx config builder
NginxConfig is a fluent builder. Every setter returns the builder, and render() returns the config string.
| Method | Notes |
|---|---|
serverNames(string ...$names) |
Required. Each name goes through Domain::normalize(). |
root(string $path) |
Document root. |
index(string ...$files) |
Defaults to index.php index.html with PHP, otherwise index.html index.htm. |
php(string $socket) |
PHP-FPM socket. Can't be combined with proxyPass(). |
proxyPass(string $url) |
Reverse proxy for Node, Python and similar apps. Can't be combined with root() or php(). |
ssl(string $certPath, string $keyPath) |
Enables HTTPS. |
redirectHttps(bool $enabled = true) |
Adds a port-80 redirect block. On by default when SSL is set. |
gzip(bool $enabled = true) |
Off by default. |
clientMaxBodySize(string $size) |
For example "64M". |
render(): string |
Throws ConfigException on missing or conflicting options, or on any value containing ;, {, } or a newline. |
PHP site with HTTPS:
use HostFog\Toolkit\Nginx\NginxConfig; $conf = (new NginxConfig()) ->serverNames('Example.com', 'www.example.com') ->root('/home/example/public_html') ->php('/run/php/php8.3-fpm.sock') ->ssl('/etc/ssl/example.com/fullchain.pem', '/etc/ssl/example.com/privkey.pem') ->gzip() ->clientMaxBodySize('64M') ->render(); file_put_contents('/etc/nginx/sites-available/example.com.conf', $conf);
Reverse proxy, serving HTTP and HTTPS without a redirect:
$conf = (new NginxConfig()) ->serverNames('app.example.io') ->proxyPass('http://127.0.0.1:3000') ->ssl('/etc/ssl/app/fullchain.pem', '/etc/ssl/app/privkey.pem') ->redirectHttps(false) ->render();
Static site:
echo (new NginxConfig())->serverNames('static.example.org')->root('/var/www/static')->render();
server { listen 80; listen [::]:80; server_name static.example.org; root /var/www/static; index index.html index.htm; location / { try_files $uri $uri/ =404; } location ~ /\.(?!well-known) { deny all; } }
Health checks
Health::http(string $url, float $timeout = 10.0, ?callable $transport = null): array
Makes a single GET request without following redirects. It never throws on network errors.
use HostFog\Toolkit\Health; Health::http('https://example.com'); // ['url' => 'https://example.com', 'ok' => true, 'status' => 200, 'timeMs' => 84, 'error' => null] Health::http('https://down.example.com', 3.0); // ['url' => '...', 'ok' => false, 'status' => null, 'timeMs' => 3001, 'error' => 'Connection timed out ...']
ok is true for statuses 200 to 399. The optional transport has the signature (string $url, array $headers): array{status: int, body: string} and throws to signal a network error.
Health::port(string $host, int $port, float $timeout = 3.0): bool
Health::port('db.example.com', 3306); // true if a TCP connection succeeds Health::port('127.0.0.1', 6379, 1.0); // false if closed
Util
Util::formatBytes(int|float $bytes, int $decimals = 1): string
Base 1024 with the units B, KB, MB, GB, TB, PB. Trailing zeros are stripped. Negative input throws InvalidArgumentException.
use HostFog\Toolkit\Util; Util::formatBytes(1023); // "1023 B" Util::formatBytes(1536); // "1.5 KB" Util::formatBytes(1073741824); // "1 GB" Util::formatBytes(1234567890, 2); // "1.15 GB"
Util::generatePassword(int $length = 20, bool $symbols = true): string
Uses random_int() (a CSPRNG). The result always contains an uppercase letter, a lowercase letter, a digit and, with symbols on, one of !@#$%^&*-_=+. A length under 8 throws InvalidArgumentException.
Util::generatePassword(); // e.g. "q7#Lm-2Rk!xP9z@aTb4W" Util::generatePassword(32); Util::generatePassword(16, false); // letters and digits only
Error handling
Every toolkit error extends HostFog\Toolkit\Exception\ToolkitException, which extends \RuntimeException.
| Exception | Thrown by |
|---|---|
InvalidDomainException |
Domain::normalize(), Dns lookups, NginxConfig::serverNames() |
DnsException |
Dns: unsupported type, resolver error status, HTTP error, invalid JSON, network failure |
SslException |
Ssl::certificate() (connection, handshake, trust) and Ssl::parsePem() |
ConfigException |
NginxConfig::render() |
Util throws PHP's built-in \InvalidArgumentException for bad arguments.
use HostFog\Toolkit\Exception\DnsException; use HostFog\Toolkit\Exception\ToolkitException; try { $records = (new Dns())->lookup($userInput, 'MX'); } catch (DnsException $e) { // resolver trouble } catch (ToolkitException $e) { // anything else from the toolkit, e.g. an invalid domain }
Requirements
- PHP 8.1 or newer
- ext-curl (DNS and HTTP health checks), ext-openssl (SSL), ext-json
- Optional: ext-intl for IDN conversion (a built-in punycode encoder is used without it) and ext-mbstring for Unicode lowercasing
There are no Composer runtime dependencies.
Development
composer install composer test # or: vendor/bin/phpunit
The tests run fully offline. They use the shared cross-language fixtures in tests/fixtures/ and start local HTTP and TLS servers on 127.0.0.1.
Publishing
This package has no publish workflow. Publishing to Packagist is a one-time step: submit https://github.com/hostfog/toolkit-php at packagist.org/packages/submit. After that, the Packagist GitHub hook picks up every new tag automatically, so releasing is just:
git tag v0.1.0 && git push origin v0.1.0
Other languages
| Language | Toolkit | API client |
|---|---|---|
| PHP | hostfog/toolkit-php (you are here) | hostfog/hostfog-php |
| JavaScript | hostfog/toolkit-js | hostfog/hostfog-js |
| Python | hostfog/toolkit-python | hostfog/hostfog-python |
All three toolkits share one specification and the same test fixtures, so they behave identically.
Licence
MIT. Copyright (c) 2026 HostFog. See LICENSE.