A Laravel package for onboarding tenant and customer hostnames with DNS verification and a Laravel Forge SSL provisioning flow.
Multi-tenant SaaS applications often allow customers to bring their own domains. This package automates the lifecycle from the moment a hostname is submitted until its SSL certificate is active and confirmed on Laravel Forge. It handles DNS verification (CNAME or TXT), Forge API calls, SSL polling, lifecycle events, reconciliation, and renewal, all behind a small, stable facade.
- DNS verification via CNAME or TXT record checks
- Laravel Forge provisioning with SSL creation and activation polling
- Wildcard driver for subdomain hostnames that do not need Forge provisioning
- Lifecycle events for verified, provisioning, activated, failed, and removed states
- Artisan commands for SSL renewal and domain reconciliation
- Shipped
ManagedDomainEloquent model (UUID primary key) or bring your own model via theProvisionableDomaincontract and theHasProvisionableDomaintrait - Test helpers:
FakeForgeandFakeDnsResolverfor in-process testing without network calls - Master kill-switch (
FORGE_DOMAIN_MANAGE) so the package can be installed before Forge credentials exist
- PHP 8.3 or higher
- Laravel 12 or 13
Install via Composer:
composer require plin-code/laravel-forge-domainPublish the configuration file:
php artisan vendor:publish --tag="forge-domain-config"Publish and run the migration:
php artisan vendor:publish --tag="forge-domain-migrations"
php artisan migrateCall ForgeDomain::onboard() after persisting the domain record. The facade dispatches a VerifyDomainJob that checks DNS, then hands off to the provisioning driver.
use PlinCode\LaravelForgeDomain\Facades\ForgeDomain;
use PlinCode\LaravelForgeDomain\Models\ManagedDomain;
use PlinCode\LaravelForgeDomain\Support\DomainKind;
$domain = ManagedDomain::create([
'hostname' => 'app.customer.com',
'kind' => DomainKind::Custom,
]);
ForgeDomain::onboard($domain);To remove a domain from Forge:
ForgeDomain::remove($domain);After publishing, the config lives at config/forge-domain.php.
Maps each DomainKind value to a provisioner driver name. The defaults are:
'drivers' => [
'custom' => 'forge',
'subdomain' => 'wildcard',
],Master kill-switch. When false, the forge driver logs operations instead of calling the Forge API. Useful before Forge credentials exist.
FORGE_DOMAIN_MANAGE=true
Forge API credentials and target server/site identifiers.
| Key | Env var | Description |
|---|---|---|
token |
FORGE_DOMAIN_TOKEN |
Forge personal access token |
organization |
FORGE_DOMAIN_ORGANIZATION |
Forge organization slug (optional) |
server_id |
FORGE_DOMAIN_SERVER_ID |
ID of the target Forge server |
site_id |
FORGE_DOMAIN_SITE_ID |
ID of the target Forge site |
server_ip |
FORGE_DOMAIN_SERVER_IP |
Public IP of the server (used for A-record checks) |
Controls how DNS ownership is confirmed before provisioning.
| Key | Env var | Description |
|---|---|---|
method |
FORGE_DOMAIN_VERIFICATION |
cname or txt (default cname) |
cname_target |
FORGE_DOMAIN_CNAME_TARGET |
The CNAME value customers must point at |
txt_prefix |
(hardcoded) | Prefix for the TXT record name (default _forge-verify) |
| Key | Default | Description |
|---|---|---|
active_days |
90 |
Expected SSL validity window in days |
renew_days_before |
14 |
Days before expiry at which renewal is triggered |
poll_tries |
15 |
Number of polling attempts when waiting for Forge to activate the certificate |
poll_backoff |
30 |
Seconds between polling attempts |
| Key | Default | Description |
|---|---|---|
mode |
log |
Set to cleanup to have the reconciler delete orphaned Forge domains automatically |
Swap out the shipped ManagedDomain model with your own:
'models' => [
'managed_domain' => \App\Models\Domain::class,
],The forge driver calls the Laravel Forge API to create a domain entry and issue an SSL certificate for the hostname. It polls until the certificate is active, then dispatches DomainActivated. When FORGE_DOMAIN_MANAGE is false the driver logs each step and returns without touching the API.
The wildcard driver is a no-op provisioner intended for subdomains already covered by a wildcard SSL certificate. It transitions the domain directly to active without any Forge API calls.
The drivers config key maps the string value of DomainKind to a driver name:
// DomainKind::Custom->value === 'custom' => 'forge'
// DomainKind::Subdomain->value === 'subdomain' => 'wildcard'You can point either kind at a custom driver name and register your own DomainProvisioner implementation in the service container.
Before provisioning, the package verifies DNS ownership using the method configured in verification.method.
CNAME: the verifier resolves the CNAME record for the hostname and checks it matches verification.cname_target.
TXT: the verifier resolves TXT records for {txt_prefix}.{hostname} and checks that one of them contains the domain's verification_token.
The domain model stores which method was requested in its verification_method column.
The package ships PlinCode\LaravelForgeDomain\Models\ManagedDomain, which uses UUID primary keys and the forge_domains table. It implements ProvisionableDomain via the HasProvisionableDomain trait and is ready to use out of the box.
Implement ProvisionableDomain on any Eloquent model and add the HasProvisionableDomain trait for the default implementation:
use Illuminate\Database\Eloquent\Model;
use PlinCode\LaravelForgeDomain\Concerns\HasProvisionableDomain;
use PlinCode\LaravelForgeDomain\Contracts\ProvisionableDomain;
use PlinCode\LaravelForgeDomain\Support\DomainKind;
use PlinCode\LaravelForgeDomain\Support\DomainStatus;
use PlinCode\LaravelForgeDomain\Support\VerificationMethod;
class Domain extends Model implements ProvisionableDomain
{
use HasProvisionableDomain;
protected $casts = [
'kind' => DomainKind::class,
'status' => DomainStatus::class,
'verification_method' => VerificationMethod::class,
'ssl_expires_at' => 'datetime',
];
}Your table must include these columns: hostname, kind, status, verification_method (nullable), verification_token (nullable), dns_target (nullable), forge_domain_id (nullable unsigned bigint), ssl_expires_at (nullable timestamp), and failure_reason (nullable text).
Update the config to point at your model:
'models' => [
'managed_domain' => \App\Models\Domain::class,
],The interface that all domain models must satisfy:
interface ProvisionableDomain
{
public function getKey(): mixed;
public function getHostname(): string;
public function getKind(): DomainKind;
public function getVerificationMethod(): ?VerificationMethod;
public function getVerificationToken(): ?string;
public function getDnsTarget(): ?string;
public function getForgeDomainId(): ?int;
public function setForgeDomainId(?int $id): void;
public function getStatus(): DomainStatus;
public function markVerified(): void;
public function markProvisioning(): void;
public function markSslActive(\DateTimeInterface $expiresAt): void;
public function markFailed(string $reason): void;
public function markRemoved(): void;
}All events carry a public $domain property typed as ProvisionableDomain.
| Event | Fired when |
|---|---|
DomainVerified |
DNS verification passes |
DomainProvisioning |
Forge provisioning begins |
DomainActivated |
SSL certificate is confirmed active |
DomainFailed |
Any step fails permanently |
DomainRemoved |
The domain is deleted from Forge |
Register listeners in your EventServiceProvider or using #[AsEventListener]:
use PlinCode\LaravelForgeDomain\Events\DomainActivated;
public function handle(DomainActivated $event): void
{
$event->domain->getHostname(); // 'app.customer.com'
}Queries for domains whose ssl_expires_at is within the configured renew_days_before window and dispatches RenewSslJob for each one. Run this on a daily schedule:
// routes/console.php
Schedule::command('forge-domain:renew-ssl')->daily();Dispatches ReconcileDomainsJob, which compares the domain IDs stored in your database against the domain IDs returned by the Forge API and reports (or cleans up) any divergence. When reconcile.mode is log, divergences are written to the application log. When set to cleanup, orphaned Forge domains are deleted.
Warning. Setting
reconcile.modetocleanupwill permanently delete every Forge domain on the configured site that the package does not track. Only enable this option when the Forge site is dedicated exclusively to package-managed domains (any manually created domain on that site will be removed without further confirmation).
Schedule::command('forge-domain:reconcile')->weekly();The package ships two test fakes. Swap them in with Laravel's bind or instance helpers in your test setup.
An in-memory ForgeClient that records creates, certificate state changes, and deletes without hitting the Forge API.
use PlinCode\LaravelForgeDomain\Support\FakeForge;
use PlinCode\LaravelForgeDomain\Contracts\ForgeClient;
$fake = new FakeForge();
$this->app->instance(ForgeClient::class, $fake);
// Force a certificate to report as active
$fake->setActive($forgeDomainId, true);
// Assert a domain was created
expect($fake->created)->toHaveKey(1);An in-memory DnsResolver that lets you seed CNAME, A, and TXT records per hostname.
use PlinCode\LaravelForgeDomain\Support\FakeDnsResolver;
use PlinCode\LaravelForgeDomain\Contracts\DnsResolver;
$resolver = new FakeDnsResolver();
$resolver->setCname('app.customer.com', ['proxy.myapp.com']);
$resolver->setTxt('_forge-verify.app.customer.com', ['forge-abc123']);
$this->app->instance(DnsResolver::class, $resolver);Run the test suite:
composer testPlease see CHANGELOG for recent changes.
Please see CONTRIBUTING for details.
Please review our security policy on how to report security vulnerabilities.
The MIT License (MIT). Please see License File for more information.