Add first-class hosting subdomain create/attach in Domains panel.
Deploy Ladill Hosting / deploy (push) Successful in 1m9s

Customers can create label-based subdomains under primary/addon sites with a separate max_domains×5 quota, nginx/docroot/SSL provisioning, and managed DNS A records when Ladill controls the parent zone.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-13 12:08:41 +00:00
co-authored by Cursor
parent c12c1cf7c3
commit 460edb8719
10 changed files with 723 additions and 11 deletions
@@ -7,6 +7,7 @@ use App\Jobs\ProvisionHostingSslJob;
use App\Models\AppInstallation;
use App\Models\CustomerHostingOrder;
use App\Models\Domain;
use App\Models\DomainDnsRecord;
use App\Models\HostedDatabase;
use App\Models\HostedSite;
use App\Models\HostingAccount;
@@ -15,6 +16,7 @@ use App\Models\HostingOrder;
use App\Models\RcServiceOrder;
use App\Models\Website;
use App\Models\WebsiteMember;
use App\Services\Dns\PowerDnsClient;
use App\Services\Domain\DomainDnsBlueprintService;
use App\Services\Domain\DomainVerificationService;
use App\Services\Hosting\Contracts\PanelRuntimeInterface;
@@ -1074,10 +1076,21 @@ class HostingPanelController extends Controller
$account->load(['node', 'product', 'sites']);
$ownedDomains = $this->availableHostingDomainsForUser($request->user()->id, $account);
$parentSites = $account->sites
->whereIn('type', ['primary', 'addon'])
->sortBy(fn (HostedSite $site) => [$site->type !== 'primary', $site->domain])
->values();
return view('hosting.panel.domains', [
'account' => $account,
'ownedDomains' => $ownedDomains,
'parentSites' => $parentSites,
'maxDomains' => $account->maxDomainsLimit(),
'maxSubdomains' => $account->maxSubdomainsLimit(),
'domainSitesCount' => $account->domainSitesCount(),
'subdomainSitesCount' => $account->subdomainSitesCount(),
'canAddDomain' => $account->canAddDomain(),
'canAddSubdomain' => $account->canAddSubdomain(),
]);
}
@@ -1091,12 +1104,15 @@ class HostingPanelController extends Controller
$this->authorizeForRequestUser($request, 'manageDomains', $account);
$account->load(['node', 'product', 'sites']);
$maxDomains = $account->resource_limits['max_domains'] ?? $account->product?->max_domains ?? 1;
if ($account->sites->count() >= $maxDomains) {
if (! $account->canAddDomain()) {
$maxDomains = $account->maxDomainsLimit();
$message = $maxDomains === -1
? 'You cannot add more domains right now.'
: "You have reached the maximum number of domains ({$maxDomains}).";
if ($request->wantsJson()) {
return response()->json(['message' => "You have reached the maximum number of domains ({$maxDomains})."], 422);
return response()->json(['message' => $message], 422);
}
return back()->with('error', "You have reached the maximum number of domains ({$maxDomains}).");
return back()->with('error', $message);
}
$isOwnedDomain = $request->boolean('is_owned_domain');
@@ -1283,15 +1299,158 @@ class HostingPanelController extends Controller
}
}
public function addSubdomain(
Request $request,
HostingAccount $account,
PowerDnsClient $pdns,
): RedirectResponse|JsonResponse {
$this->authorizeForRequestUser($request, 'manageDomains', $account);
$account->load(['node', 'product', 'sites.domain']);
if (! $account->canAddSubdomain()) {
$maxSubdomains = $account->maxSubdomainsLimit();
$message = $maxSubdomains === -1
? 'You cannot add more subdomains right now.'
: "You have reached the maximum number of subdomains ({$maxSubdomains}).";
if ($request->wantsJson()) {
return response()->json(['message' => $message], 422);
}
return back()->with('error', $message);
}
$validated = $request->validate([
'parent_site_id' => ['required', 'integer'],
'subdomain' => ['required', 'string', 'max:63', 'regex:/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/'],
'document_root' => ['nullable', 'string', 'max:255'],
]);
$label = strtolower(trim($validated['subdomain']));
$reserved = ['www', 'mail', 'ftp', 'ns1', 'ns2'];
if (in_array($label, $reserved, true)) {
if ($request->wantsJson()) {
return response()->json(['message' => "The subdomain \"{$label}\" is reserved."], 422);
}
return back()->with('error', "The subdomain \"{$label}\" is reserved.");
}
$parent = $account->sites
->first(fn (HostedSite $site) => (int) $site->id === (int) $validated['parent_site_id']
&& in_array($site->type, ['primary', 'addon'], true));
if (! $parent) {
if ($request->wantsJson()) {
return response()->json(['message' => 'Choose a parent domain attached to this hosting account.'], 422);
}
return back()->with('error', 'Choose a parent domain attached to this hosting account.');
}
$fqdn = $label.'.'.strtolower(trim((string) $parent->domain));
$serverIp = $account->node?->ip_address ?? '161.97.138.149';
$docRoot = ! empty($validated['document_root'])
? '/home/'.$account->username.'/'.ltrim($validated['document_root'], '/')
: '/home/'.$account->username.'/public_html/'.$fqdn;
$existingSite = HostedSite::withTrashed()->where('domain', $fqdn)->first();
if ($existingSite) {
if ($existingSite->trashed() && (int) $existingSite->hosting_account_id === (int) $account->id) {
$existingSite->restore();
$existingSite->fill([
'document_root' => $docRoot,
'php_version' => $account->php_version ?? $existingSite->php_version ?? '8.2',
'status' => 'active',
'type' => 'subdomain',
'domain_id' => $parent->domain_id,
])->save();
try {
$existingSite->loadMissing(['account.node']);
$this->runtimeFor($account)->addSite($existingSite);
$this->provisionSubdomainDns($account, $parent, $label, $fqdn, $serverIp, $pdns);
ProvisionHostingSslJob::dispatch($existingSite->id)->delay(now()->addMinutes(2));
if ($request->wantsJson()) {
return response()->json(['success' => true, 'message' => "Subdomain {$fqdn} has been restored."]);
}
return back()->with('success', "Subdomain {$fqdn} has been restored. SSL will be provisioned automatically.");
} catch (\Exception $e) {
$existingSite->delete();
Log::error('Failed to restore subdomain nginx config: '.$e->getMessage());
if ($request->wantsJson()) {
return response()->json(['message' => 'Failed to restore subdomain. Please try again.'], 500);
}
return back()->with('error', 'Failed to restore subdomain. Please try again.');
}
}
if ((int) $existingSite->hosting_account_id !== (int) $account->id) {
if ($request->wantsJson()) {
return response()->json(['message' => 'This subdomain is already in use on another hosting account.'], 422);
}
return back()->with('error', 'This subdomain is already in use on another hosting account.');
}
if ($request->wantsJson()) {
return response()->json(['success' => true, 'message' => "Subdomain {$fqdn} is already linked to this hosting account."]);
}
return back()->with('success', "Subdomain {$fqdn} is already linked to this hosting account.");
}
$site = HostedSite::create([
'hosting_account_id' => $account->id,
'domain_id' => $parent->domain_id,
'domain' => $fqdn,
'document_root' => $docRoot,
'type' => 'subdomain',
'php_version' => $account->php_version ?? '8.2',
'ssl_enabled' => false,
'status' => 'active',
]);
try {
$this->runtimeFor($account)->addSite($site);
$dnsManaged = $this->provisionSubdomainDns($account, $parent, $label, $fqdn, $serverIp, $pdns);
ProvisionHostingSslJob::dispatch($site->id)->delay(now()->addMinutes(2));
$message = $dnsManaged
? "Subdomain {$fqdn} created. DNS A record was added for the parent zone; SSL will provision automatically."
: "Subdomain {$fqdn} created. Point an A record for {$fqdn} to {$serverIp}, then SSL will provision automatically.";
if ($request->wantsJson()) {
return response()->json(['success' => true, 'message' => $message]);
}
return back()->with('success', $message);
} catch (\Exception $e) {
$site->delete();
Log::error('Failed to add subdomain: '.$e->getMessage());
if ($request->wantsJson()) {
return response()->json(['message' => 'Failed to create subdomain. Please try again.'], 500);
}
return back()->with('error', 'Failed to create subdomain. Please try again.');
}
}
public function removeDomain(Request $request, HostingAccount $account, HostedSite $site): RedirectResponse
{
abort_unless((int) $account->user_id === (int) $request->user()->id, 403);
$this->ensureSiteBelongsToAccount($site, $account);
$account->load(['node']);
$site->loadMissing('domain');
try {
$this->runtimeFor($account)->removeSite($site);
$this->cleanupSubdomainDns($site);
$site->delete();
return back()->with('success', 'Domain removed successfully.');
@@ -1301,6 +1460,105 @@ class HostingPanelController extends Controller
}
}
/**
* @return bool True when DNS was managed/updated automatically
*/
private function provisionSubdomainDns(
HostingAccount $account,
HostedSite $parent,
string $label,
string $fqdn,
string $serverIp,
PowerDnsClient $pdns,
): bool {
// HostedSite has both a `domain` column and a domain() relation — load via domain_id.
$parentDomain = $parent->domain_id
? Domain::query()->find($parent->domain_id)
: null;
if (! $parentDomain || $parentDomain->dns_mode !== 'managed') {
return false;
}
try {
$pdns->upsertRecords((string) $parentDomain->host, [[
'name' => $fqdn,
'type' => 'A',
'ttl' => 3600,
'contents' => [$serverIp],
]]);
DomainDnsRecord::query()->updateOrCreate(
[
'domain_id' => $parentDomain->id,
'name' => $label,
'type' => 'A',
'value' => $serverIp,
],
[
'ttl' => 3600,
'source' => 'hosting_subdomain',
'status' => 'active',
'notes' => 'Subdomain for hosting account '.$account->id,
'last_checked_at' => now(),
],
);
return true;
} catch (\Throwable $e) {
Log::warning('Failed to provision subdomain DNS record', [
'fqdn' => $fqdn,
'parent_domain_id' => $parentDomain->id,
'error' => $e->getMessage(),
]);
return false;
}
}
private function cleanupSubdomainDns(HostedSite $site): void
{
if ($site->type !== 'subdomain' || ! $site->domain_id) {
return;
}
$parentDomain = Domain::query()->find($site->domain_id);
if (! $parentDomain || $parentDomain->dns_mode !== 'managed') {
return;
}
$host = strtolower(trim((string) $site->getAttribute('domain')));
$apex = strtolower((string) $parentDomain->host);
$suffix = '.'.$apex;
if (! str_ends_with($host, $suffix)) {
return;
}
$label = substr($host, 0, -strlen($suffix));
if ($label === '' || str_contains($label, '.')) {
return;
}
try {
app(PowerDnsClient::class)->deleteRecords($apex, [[
'name' => $host,
'type' => 'A',
]]);
} catch (\Throwable $e) {
Log::info('Best-effort subdomain DNS cleanup failed', [
'domain' => $host,
'error' => $e->getMessage(),
]);
}
DomainDnsRecord::query()
->where('domain_id', $parentDomain->id)
->where('name', $label)
->where('type', 'A')
->where('source', 'hosting_subdomain')
->delete();
}
private function availableHostingDomainsForUser(int $userId, HostingAccount $account): \Illuminate\Support\Collection
{
$linkedDomains = HostedSite::whereNotNull('domain')
@@ -209,7 +209,7 @@ class HostingProductController extends Controller
$renewals ??= app(HostingRenewalCheckoutService::class);
$account->load(['product', 'user', 'node', 'sites']);
$maxDomains = $account->resource_limits['max_domains'] ?? $account->product?->max_domains ?? 1;
$maxDomains = $account->maxDomainsLimit();
$freeEmailAllowance = $account->freeEmailAllowance();
$mailboxes = $account->loadedMailboxes();
$mailboxCount = $mailboxes->count();
@@ -219,7 +219,7 @@ class HostingProductController extends Controller
'account' => $account,
'linkedSites' => $account->sites->sortBy(fn ($site) => [$site->type !== 'primary', $site->domain])->values(),
'maxDomains' => $maxDomains,
'canLinkMoreDomains' => $account->sites->count() < $maxDomains,
'canLinkMoreDomains' => $account->canAddDomain(),
'ownedDomains' => collect($this->getUserDomains($request->user())),
'emailUsage' => [
'mailbox_count' => $mailboxCount,