Search by

porkbun-php / client

glebovdev

PHP client for the Porkbun API v3 with domain-centric design, typed DTOs, and complete endpoint coverage

Package info

github.com/porkbun-php/client

pkg:composer/porkbun-php/client

Statistics

Installs: 355

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 6

v1.4.0 2026-11-01 14:30 UTC

README

Porkbun PHP API Client

Porkbun PHP API Client

Tests Code Quality Latest Stable Version License

A community-maintained PHP 8.4+ client for the Porkbun API v3 with domain-centric design and Laravel integration.

Features

  • Core API coverage — 37 Porkbun API v3 endpoints: DNS, DNSSEC, SSL, nameservers, URL forwarding, glue records, contacts, domains, transfers, pricing, and more
  • Domain-centric design — fluent API: $client->domain('example.com')->dns()->all()
  • Typed everything — immutable DTOs, backed enums, strict return types throughout
  • Fluent builders — validated DNS record construction with convenience methods
  • Structured errors — typed exception hierarchy with request/response context
  • Laravel integration — service provider, facade, and config publishing out of the box
  • PSR-18 compatible — works with any HTTP client (Guzzle, Symfony, curl, etc.)
  • Strict quality — Pint, Pest, PHPStan, PHP 8.4+ with Rector

Requirements

  • PHP 8.4+
  • A PSR-18 HTTP client (Guzzle, Symfony HttpClient, or any other)

Installation

composer require porkbun-php/client

If you don't already have a PSR-18 HTTP client installed, add one:

composer require guzzlehttp/guzzle
# or
composer require symfony/http-client nyholm/psr7

Tip

Most frameworks already ship with a PSR-18 client — Laravel includes Guzzle, Symfony includes its HttpClient. The package auto-discovers it, so new Porkbun\Client() just works.

Quick Start

Generate your API key and secret on the API Access page. To manage a specific domain via API, enable API Access for that domain in your Porkbun control panel.

$client = new Porkbun\Client();
$client->authenticate('pk1_your_api_key', 'sk1_your_secret_key');

// Test connectivity
$ping = $client->ping();
echo "Your IP: {$ping->resolvedIp}";

// Domain pricing (no auth required)
$pricing = $client->pricing()->all();
echo "COM: $" . $pricing->find('com')?->registrationPrice;

// Domain-centric operations
$domain = $client->domain('example.com');

// List DNS records
foreach ($domain->dns()->all() as $record) {
    echo "{$record->name} {$record->type->value} {$record->content}\n";
}

// Get SSL certificate
$cert = $domain->ssl();
echo $cert->certificateChain;
Advanced configuration
// Custom PSR-18 HTTP client
$client = new Porkbun\Client($myPsr18Client);

// IPv4-only endpoint (useful for dynamic DNS)
$client->useIpv4Endpoint();
$client->useDefaultEndpoint(); // back to dual-stack

// Switch accounts at runtime
$client->authenticate($account2Key, $account2Secret);
$client->clearAuth(); // back to unauthenticated

API Reference

Note

All endpoints require authentication except Pricing — you can query TLD pricing without API keys.

Pricing (No Auth Required)

$pricing = $client->pricing()->all();

$pricing->find('com')?->registrationPrice;  // float
$pricing->find('com')?->renewalPrice;       // float
$pricing->cheapest(10);                     // Top 10 cheapest TLDs
$pricing->tlds();                           // All available TLD keys

Ping (Auth Test)

$ping = $client->ping();

$ping->resolvedIp;  // Your IP address (prefers forwarded IP)
$ping->forwardedIp; // Forwarded IP (from X-Forwarded-For header)
$ping->yourIp;      // Raw IP from API response

Domains

// List all domains (iterates all pages automatically)
foreach ($client->domains()->all() as $domain) {
    echo "{$domain->domain} expires {$domain->expireDate?->format('Y-m-d')}\n";
}

// Single page with pagination metadata
$page = $client->domains()->list();
$page->domains(); // DomainCollection (also available via iteration/count/json on $page itself)
$page->hasMore;    // bool — true if more pages exist
$page->nextStart;  // ?int — pass to list() for the next page
$page->start;      // int — current offset

// PaginatedResult is iterable, countable, and JSON-serializable:
count($page);              // number of domains on this page
json_encode($page);        // serializes with pagination metadata
foreach ($page as $domain) { /* ... */ }

// Paginate manually
$page = $client->domains()->list(start: 0, includeLabels: true);
while ($page->hasMore) {
    $page = $client->domains()->list(start: $page->nextStart);
}

// Find a specific domain
$domain = $client->domains()->find('example.com'); // Domain DTO or null

// Bulk auto-renewal management
$client->domains()->enableAutoRenew('example.com', 'other.com');
$client->domains()->disableAutoRenew('example.com');

Domain Details

$domain = $client->domain('example.com');
$info = $domain->details();      // Domain DTO
$domain->details(includeLabels: true);   // also fills ->labels

$info->domain;                   // 'example.com'
$info->status;                   // 'ACTIVE'
$info->expireDate;               // ?DateTimeImmutable
$info->autoRenew;                // ?bool
$info->tld;                      // 'com'

A domain that is not in the account throws ApiException with getErrorCode() === 'DOMAIN_NOT_FOUND' (HTTP 404). Like every other domain method, details() may fail with API_ACCESS_DISABLED for a domain that is not opted in to API access; $client->domains()->find('example.com') reads any domain in the account.

Domain Availability

$result = $client->domain('example.com')->check();

$result->isAvailable;           // bool
$result->price;                 // ?float (registration price)
$result->type;                  // string — 'standard', 'premium', etc.
$result->priceInCents;          // ?int (e.g., 999 for $9.99)
$result->renewalPriceInCents;   // ?int (pass to renew())
$result->transferPriceInCents;  // ?int (pass to transfer()->initiate())
$result->effectivePrice;        // ?float (promo price if available, else regular)

Registration Requirements

$req = $client->domains()->registrationRequirements('us');   // a TLD, not a domain: 'co.uk', not 'example.co.uk'

$req->apiRegisterable;            // bool: false means register on the website only
$req->notApiRegisterableReason;   // ?string, set when apiRegisterable is false
$req->registrationDurationYears;  // int: fixed term the API registers for (2 for .ai)
$req->maxRegistrationYears;       // ?int
$req->whoisPrivacySupported;      // bool
$req->requiresValidatedAddress;   // bool
$req->registrantOnly;             // bool: only the registrant contact is used
$req->requestSchema;              // array: JSON Schema of the /domain/create body
$req->registryRequirements;       // ?array: JSON Schema of registry eligibility fields (.us, .ca, .de, ...)

registryRequirements only describes the registry's eligibility rules: register() cannot send those fields. An unknown TLD throws ApiException with getErrorCode() === 'INVALID_INPUT'.

Domain Registration

$result = $client->domain('newdomain.com')->register(868);

$result->domain;                 // 'newdomain.com'
$result->orderId;                // int
$result->costInCents;            // int
$result->costInDollars;          // float (computed)
$result->balanceInCents;         // int
$result->balanceInDollars;       // float (computed)

Domain Renewal

$domain = $client->domain('example.com');
$result = $domain->renew($domain->check()->renewalPriceInCents);

$result->domain;                 // 'example.com'
$result->expirationDate;         // ?DateTimeImmutable (new expiration date)
$result->orderId;                // int
$result->costInCents;            // int
$result->costInDollars;          // float (computed)
$result->balanceInCents;         // int
$result->balanceInDollars;       // float (computed)

Domain Transfers

$domain = $client->domain('example.com');
$transfer = $domain->transfer(); // takes no arguments: start a transfer with initiate()

// The auth code (EPP code) comes from the current registrar
$result = $transfer->initiate($authCode, $domain->check()->transferPriceInCents); // DomainTransfer
$result->orderId;           // int
$result->transferId;        // int
$result->balanceInCents;    // int
$result->message;           // ?string

// Track it (production transfers usually take 5-7 days)
$record = $transfer->details();  // TransferRecord
$record->status;                 // string: 'PENDINGTRANSFER', 'DONE', 'CANCELED', ...
$record->statusDescription;      // string
$record->transferDate;           // ?DateTimeImmutable (UTC)

// Replace a wrong auth code, or cancel and refund
$transfer->updateAuthCode($newAuthCode); // TransferAuthCodeUpdate
$transfer->cancel();                     // TransferCancellation (refunded, refundAmountInCents, ...)

// Active transfers in the account (done and canceled ones are not listed)
$client->domains()->transfers();         // TransferRecordCollection of TransferRecord

initiate() pays from account credit, so don't retry it automatically (for example in a queue job): check details() first. Failures throw ApiException; check getErrorCode() for DOMAIN_ALREADY_IN_ACCOUNT, COST_MISMATCH (the message names the expected cost), INVALID_AUTH_CODE from updateAuthCode(), and TRANSFER_NOT_FOUND when the domain has no transfer.

DNS Records

Tip

Use the builder for validated record creation, or direct methods when you need to bypass client-side validation.

$dns = $client->domain('example.com')->dns();

// Retrieve
$dns->all();                          // DnsRecordCollection
$dns->find($recordId);               // DnsRecord or null
$dns->findByType('A');                // DnsRecordCollection
$dns->findByType('A', 'www');         // By type and subdomain
// Create (direct)
$result = $dns->create('A', 'www', '192.0.2.1', ttl: 3600);
echo "Created record: {$result->id}";

// Create (builder — recommended)
$result = $dns->createFromBuilder(
    $dns->record()
        ->a('192.0.2.2')
        ->name('api')
        ->ttl(3600)
        ->notes('API server')
);

// Builder convenience methods
$dns->record()->mx('mail.provider.com', priority: 10)->name('mail');
$dns->record()->txt('v=DMARC1; p=reject')->name('_dmarc');
$dns->record()->cname('blog.provider.com')->name('blog');
$dns->record()->aaaa('2001:db8::1')->name('app');

// Enum types are also accepted
use Porkbun\Enum\DnsRecordType;
$dns->findByType(DnsRecordType::A);
$dns->create(DnsRecordType::A, 'www', '192.0.2.1');

// Update (direct or builder): ttl and priority you leave out keep their current values
// (the builder's mx() always sets priority, default 10)
$dns->update($recordId, 'A', 'www', '192.0.2.3');
$dns->updateFromBuilder($recordId, $dns->record()->a('192.0.2.3')->name('www'));
$dns->updateByType('A', 'www', '192.0.2.3');   // resets ttl/priority to 600/0 unless you pass them

// Delete
$dns->delete($recordId);
$dns->deleteByType('A', 'old-subdomain');

// Collection helpers (all collections support first(), last(), count())
$records = $dns->all();
$records->byType('MX');
$records->byName('www');
$records->rootRecords;
$records->byType('A')->first();

DNSSEC Records

$dnssec = $client->domain('example.com')->dnssec();

$dnssec->all();              // DnssecRecordCollection
$result = $dnssec->create(keyTag: 12345, algorithm: 13, digestType: 2, digest: 'abc123...');
$result->message;          // ?string
$dnssec->delete($keyTag);

Batch DNS Operations

$dns = $client->domain('example.com')->dns();

// Pre-wired batch builder — no need to pass $dns to execute()
$results = $dns->batch()
    ->addRecord('A', 'www', '192.0.2.1')
    ->addRecord('A', 'api', '192.0.2.2')
    ->add($dns->record()->mx('mail.example.com', priority: 10))  // builder-based add
    ->updateRecord($existingId, 'A', 'www', '192.0.2.3', ttl: 3600)
    ->deleteRecord($oldRecordId)
    ->deleteByType('TXT', 'old-subdomain')
    ->execute();

if ($results->hasFailures()) {
    echo "Some operations failed!\n";
}

foreach ($results as $result) {
    if ($result->success) {
        echo "OK: {$result->operation->value}\n";
    } else {
        echo "Failed: {$result->error}\n";
    }
}

SSL Certificates

$cert = $client->domain('example.com')->ssl();

$cert->certificateChain;
$cert->privateKey;
$cert->publicKey;
$cert->fullChain;                   // Chain + intermediate
$cert->hasPrivateKey;               // bool
$cert->hasCertificate;              // bool
$cert->hasIntermediateCertificate;  // bool

Nameservers

$ns = $client->domain('example.com')->nameservers();

$ns->all();              // NameserverCollection: ['ns1.porkbun.com', 'ns2.porkbun.com']
$ns->update('ns1.custom.com', 'ns2.custom.com');

Contacts

use Porkbun\DTO\Contact;
use Porkbun\Enum\AddressValidationChoice;

$contacts = $client->domain('example.com')->contacts();

$domainContacts = $contacts->all();
$domainContacts->registrant;         // ?Contact — also admin, tech, billing

$contact = new Contact(
    firstName: 'Jane',
    address1: '1 Example St',
    city: 'Springfield',
    postalCode: '62701',
    country: 'US',                   // ISO 3166-1 alpha-2
    phone: '5555550100',
    phoneCountryCode: '1',
    email: 'jane@example.com',
    lastName: 'Doe',
    state: 'IL',
);

$contacts->update(admin: $contact, tech: $contact);   // any subset of roles
$result = $contacts->updateAll($contact);             // all four roles
$result->registrantChanged;          // bool — true when a new-owner notice was sent

$contacts->updateAll($contact, AddressValidationChoice::ACCEPT_SUGGESTION);   // see note below

Note

On some TLDs (.de, .uk, .us, .ca, .eu and others) a registrant change can throw ApiException with $e->getErrorCode() === 'ADDRESS_VALIDATION_REQUIRED'. The suggestedAddress is in the response body ($e->getResponse()). Re-send with addressValidationChoice: AddressValidationChoice::ACCEPT_SUGGESTION to save the suggestion, or USE_AS_ENTERED to keep yours.

URL Forwarding

$forwards = $client->domain('example.com')->urlForwarding();

$forwards->all();              // UrlForwardCollection
$result = $forwards->create('https://destination.example.com', 'temporary', subdomain: 'go');
$result->message;              // ?string
$forwards->delete($recordId);

Glue Records

$glue = $client->domain('example.com')->glueRecords();

$glue->all();              // GlueRecordCollection
$result = $glue->create('ns1', '192.0.2.1', '192.0.2.2');
$result->message;          // ?string
$glue->update('ns1', '192.0.2.10');
$glue->delete('ns1');      // OperationResult

Auto-Renewal

$autoRenew = $client->domain('example.com')->autoRenew();

$result = $autoRenew->enable();   // AutoRenewResult
$result->success;                 // bool
$result->message;                 // ?string

$autoRenew->disable();

Error Handling

All exceptions implement Porkbun\Exception\ExceptionInterface for unified catching:

use Porkbun\Exception\ApiException;
use Porkbun\Exception\AuthenticationException;
use Porkbun\Exception\InvalidArgumentException;
use Porkbun\Exception\NetworkException;
use Porkbun\Exception\ExceptionInterface;

try {
    $client->domains()->list();
} catch (AuthenticationException $e) {
    // Invalid or missing API credentials
    $e->getErrorCode();   // e.g. 'INVALID_API_KEYS_001'
} catch (ApiException $e) {
    // API returned an error (4xx/5xx)
    $e->getErrorCode();   // e.g. 'INVALID_DOMAIN', null if the API sent none
    $e->getStatusCode();
    $e->getRequest();
    $e->getResponse();
} catch (NetworkException $e) {
    // HTTP/connection failure
    $e->getRequest();
} catch (InvalidArgumentException $e) {
    // Invalid parameters (bad DNS type, empty domain list, etc.)
} catch (ExceptionInterface $e) {
    // Catch-all for any library exception
}

getErrorCode() returns Porkbun's machine-readable code as a plain string. Porkbun adds new codes over time, so always handle unknown ones (a default arm in match). The same problem can also have different codes on different endpoints: a bad key is INVALID_API_KEYS on /ping and INVALID_API_KEYS_001 elsewhere, so match such families with str_starts_with().

Tip

If the default endpoint is unreachable, fall back to $client->useIpv4Endpoint(). See 08-error-handling.php for the full pattern.

Laravel Integration

The package auto-registers via Laravel's package discovery. The service provider is deferred — the client is only instantiated when you use it.

Add credentials to .env:

PORKBUN_API_KEY=pk1_your_key
PORKBUN_SECRET_KEY=sk1_your_secret
PORKBUN_ENDPOINT=default   # or 'ipv4' for IPv4-only

Optionally publish the config:

php artisan vendor:publish --tag=porkbun-config

Facade

use Porkbun\Laravel\Facades\Porkbun;

$domains = Porkbun::domains()->list();
$records = Porkbun::domain('example.com')->dns()->all();

Dependency Injection

use Porkbun\Client;

class DnsController
{
    public function index(Client $client)
    {
        return $client->domain('example.com')->dns()->all();
    }
}

Examples

See the examples/ directory for runnable scripts:

Development

composer install
composer run check    # code style + static analysis + tests
composer run fix      # auto-fix style issues
composer run test     # run test suite

License

MIT License. See LICENSE for details.