porkbun-php / client
PHP client for the Porkbun API v3 with domain-centric design, typed DTOs, and complete endpoint coverage
Requires
- php: ^8.4
- nyholm/psr7: ^1.8
- php-http/discovery: ^1.19
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^2.0
Requires (Dev)
- ergebnis/composer-normalize: ^2.45
- guzzlehttp/guzzle: ^7.9
- guzzlehttp/psr7: ^2.7
- illuminate/support: ^11.0 || ^12.0
- laravel/pint: ^1.25
- mockery/mockery: ^1.6
- pestphp/pest: ^3.8 || ^4.0
- phpstan/phpstan: ^2.1
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- rector/rector: 2.8.3
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-01 14:32:32 UTC
README
Porkbun PHP API Client
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:
01-ping.php- Auth test and IPv4 endpoint switching02-pricing.php- Public pricing API, cheapest TLDs, iteration03-domains.php- List domains, pagination, expiring soon, availability check, registration requirements, active transfers04-dns.php- DNS CRUD with direct methods, collection helpers05-dns-builder.php- Fluent builder, convenience methods, immutable templates06-dns-batch.php- Batch operations, mixed create/edit/delete07-domain-services.php- Nameservers, contacts, URL forwarding, glue records, SSL, auto-renew08-error-handling.php- Exception hierarchy, endpoint fallback pattern09-dynamic-dns.php- Real-world dynamic DNS updater recipe10-multi-account.php- Account switching, public/auth/clearAuth flow11-laravel.php- Facade usage, dependency injection, Artisan commands
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.
