Search by

hostfog / toolkit

param3511

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.

v0.1.0 2026-10-16 08:46 UTC

This package is auto-updated.

Last update: 2026-10-16 09:40:01 UTC


README

HostFog

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.

Packagist version CI MIT licence Downloads

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.