- Introduced a new POST route 'bulk-update-gateway' for bulk updating gateways. - This route is protected by the 'module-vcenter.manager' permission middleware.
26 KiB
Hosting Management Module — Phase 1 (Provisioning) Design
Scope: create Plesk Customer → create Hosting Subscription → assign Plan → persist the local record. Nothing else. Database/SSL/Mail/Git deploy/Let's Encrypt/auto-upload are explicitly out of scope and are not stubbed — no placeholder methods for them are created in this pass.
This revises the earlier proposal in this file: type is now a varchar
column on dns_manager_servers (not a capability pivot), per explicit
instruction. Everything else below is re-verified against the actual
source, not carried over by assumption.
1. Audit — what's actually in the codebase
1.1 Domain / DNS / vCenter modules
- DNS: table
dns_manager_servers(modelApp\Models\DnsServer) — the live Plesk credential store:id, name, ip_address, username, password, api_key, status, timestamps. Notype,hostname,port,protocolcolumns exist today.dns_manager_domain(modelDnsDomain) hasdns_manager_server_idFK andplesk_id.dns_manager_ns(modelDnsManagerNS) hasplesk_idtoo. Repository:App\Repositories\DnsManager\Server\DnsServerRepositoryInterface→ bound inRepositoryProvider— actually check: this specific interface/binding was not found inRepositoryProvider.php's bind list (onlyModule\DnsServer\DnsServerRepositoryInterface, a different, legacy class tied to tablemodule_dns_server, was found bound). This meansServerService(below) currently resolvesDnsManager\Server\DnsServerRepositoryInterfacefrom the container without an explicit binding visible in the audited file — either it's bound elsewhere (a second provider) or Laravel's implicit binding/ interface-to-concrete auto-resolution isn't in play and this is already fragile. Do not copy this uncertainty into the new module — bind the new interface explicitly (§6). - Plesk HTTP calls today live in
App\Services\ServerService, talking to:8443/api/v2/{server,domains,dns/records}withHttp::withoutVerifying()->withBasicAuth($username, Crypt::decryptString($password)). It already implementscreateDomain(),updateDomain(),removeDomain(),getDomainInfo(),listRecords(),checkConnection().createDomain()already POSTshosting_settings.ftp_login/ftp_password+plan— i.e. it's already creating a hosting-capable Plesk domain object, not a bare DNS zone. This is the piece to extract and reuse (§7), not reimplement. - vCenter:
App\Http\Services\vCenter\Client— same shape (Httpfacade, Basic Auth, returns raw arrays/bools), different domain. Confirms "thin service class over Http facade, no HTTP abstraction layer" is the house style, not a DNS-module one-off. - Legacy Hosting (
module_hosting,module_hosting_server,App\Http\Services\Hosting,App\Jobs\Hosting\CreateHostingJobs): a pre-servicesera, WHMCS-shaped flow. Different crypto (SignRSA, notCrypt::), different credential table, different (non-Plesk-v2-REST) API surface (CheckUserClient,CreateNewClient,CreateNewDomain,CreateExpirationForDomain), stores the account password directly onmodule_hosting.password. Not reused — instruction says no legacy tables and no duplicated data; this codebase's ownservices/productslayer already superseded it for VPS (module_vmware+services.vm_id) and Hosting should follow that same replacement, not the old one. control_panels/product_control_panel: exist, seeded (Plesk, DirectAdmin, cPanel, ...), unused by any controller/service found. Out of scope to wire up in Phase 1 (no product-selection UI requested), but worth knowing they exist so Phase 1 doesn't hardcode "Plesk" somewhere that later blocks reading the panel off the product.
1.2 Repository Pattern
Confirmed convention, three files per resource:
XRepositoryInterface extends BaseRepositoryInterface
XRepository extends BaseRepository (getModel(), query logic)
XCachedRepository extends BaseCachedRepository, implements XRepositoryInterface
(wraps XRepository, Cache::remember on read methods)
Bound in app/Providers/RepositoryProvider.php:
$this->app->singleton(XRepositoryInterface::class, XCachedRepository::class);
This is the pattern used for Module\DnsServer, Module\HostingServer,
Module\Hosting (legacy), Domain, etc. — followed as-is for the new
module, no changes to the pattern itself.
1.3 Service Pattern
Plain App\Services\* or App\Http\Services\{Domain}\* classes, no
interfaces, no DI container binding beyond constructor injection of
repositories. Return shape for external-API-calling services observed
consistently: ['error' => bool, 'data' => ..., 'message' => ...] (see
ServerService — every public method returns this shape or array|false).
Followed as-is.
1.4 Naming convention
- Tables: snake_case, no prefix for "core" entities (
customers,services,products),module_prefix for provisioned-resource tables descended from the legacy admin panel (module_vmware,module_dns_server,module_hosting). Notbl_prefix anywhere in the codebase. - Migrations:
unsignedBigInteger('x_id')->index()+ a separate$table->foreign('x_id')->references('id')->on('x')->...call is the dominant style (46 migrations) over theforeignId()shorthand (18 migrations, mostly older). Followed as the majority style for consistency with every table this module touches (dns_manager_servers,services,customers). - Migration filenames:
YYYY_MM_DD_HHMMSS_verb_noun.php, oneSchema::create/Schema::tableblock per concern; multi-step column changes get their own migration file rather than being folded into the original create (e.g.add_domain_fields_to_services.php,alter_services_vm_id_to_fk.php).
1.5 Permission convention
Seeded in database/seeders/RolesAndPermissions.php, pattern
{module}.{view|create|edit|destroy|manager}, guard admin. Routes apply
via ->middleware('perm:{module}.{action}').
A module-hosting.{view,create,edit,destroy,manager} permission set
already exists in the seeder (tied to the legacy module_hosting
admin-blade screens, no route match found in routes/api.php — i.e.
currently orphaned or admin-blade-only). Reusing it for the new API would
silently couple this module's authorization to whatever the legacy screens
still do with it. New module gets its own permission group instead:
hosting.{view,create,edit,destroy} — matches the flat domain.*/
customer.* style (no module- prefix) used for the modern,
services-era modules, as opposed to the module-* prefix used for the
older admin-panel-generation features (module-vcenter.*,
module-dns.*). This keeps "which permission style = which generation of
code" legible, which is already implicitly true in the seeder today.
2. dns_manager_servers.type
Decision: varchar, not a native SQL ENUM.
Reasons, specific to this codebase:
- Zero existing migrations use MySQL
->enum(...).dns_manager_servers.statusitself is astringcolumn with values validated by convention/PHP, not a DB enum. Matching that meanstypeshould bestringtoo — one column-kind convention for the whole table, not a mix. App\Enums\Domain\DomainProvisioningStatusshows the actual "enum" convention in this codebase: a PHP backed enum class used for validation andlabel()/transition logic, with the DB column being a plainstring. Addingbothas a third case later, or amail/cdnvalue in some future phase, is then a one-line enum case, not a migration to widen a nativeENUMdefinition (which MySQL handles as a full table metadata rewrite).- Pivot/capability table and JSON were explicitly excluded by the request;
they're also not needed here —
domain | hosting | bothis a small, stable, mutually-exclusive-enough set for a single column, unlike the earlier revision's "server can have an open-ended combination" framing.
enum ServerType: string
{
case DOMAIN = 'domain';
case HOSTING = 'hosting';
case BOTH = 'both';
}
Query helper needed because of both: any lookup for "servers usable for
hosting" must match type IN ('hosting', 'both'), not type = 'hosting'.
This is the one real cost of varchar+both over a pivot — every query
site needs to know to expand both. Put that in one place:
DnsServerRepositoryInterface::getUsableFor(ServerType $type) on the
repository, so PleskHostingService and any future admin filter never
hand-roll the IN (...) themselves.
// migration: add_type_to_dns_manager_servers.php
Schema::table('dns_manager_servers', function (Blueprint $table) {
$table->string('type', 20)->default('domain')->after('status');
});
// backfill: every existing row already serves DNS today
DB::table('dns_manager_servers')->update(['type' => 'domain']);
3. Hosting module — table name
module_hosting_accounts. Follows the module_ + resource-noun pattern
already used for module_vmware (the closest analog: a provisioned
resource hanging off services, one credential-holding parent per row).
Does not collide with legacy module_hosting/module_hosting_server
(different tables, different meaning, left untouched — not migrated into,
not read from).
4. Migration
// database/migrations/2026_08_06_130000_add_type_to_dns_manager_servers_table.php
Schema::table('dns_manager_servers', function (Blueprint $table) {
$table->string('type', 20)->default('domain')->after('status');
});
// database/migrations/2026_08_06_130001_create_module_hosting_accounts_table.php
Schema::create('module_hosting_accounts', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('customer_id')->index();
$table->unsignedBigInteger('service_id')->nullable()->index();
$table->unsignedBigInteger('dns_manager_server_id')->index();
$table->unsignedBigInteger('dns_manager_domain_id')->nullable()->index();
$table->unsignedInteger('plesk_client_id')->nullable();
$table->unsignedInteger('plesk_subscription_id')->nullable()->index();
$table->string('plesk_guid', 64)->nullable();
$table->string('hosting_plan', 100)->nullable();
$table->string('ftp_username', 100)->nullable();
$table->string('system_username', 100)->nullable();
$table->string('document_root', 255)->nullable();
// pending -> active -> suspended -> terminated (mirrors services.status vocabulary)
$table->string('status', 20)->default('pending')->index();
$table->timestamps();
$table->foreign('customer_id')->references('id')->on('customers')->onDelete('cascade');
$table->foreign('service_id')->references('id')->on('services')->onDelete('set null');
$table->foreign('dns_manager_server_id')->references('id')->on('dns_manager_servers')->onDelete('restrict');
$table->foreign('dns_manager_domain_id')->references('id')->on('dns_manager_domain')->onDelete('set null');
});
No password / FTP-password / Basic-Auth column — confirmed not needed:
dns_manager_servers already holds what's required to manage the box: the
FTP account Plesk creates is reachable/resettable through the same
Basic-Auth credential, so nothing hosting-account-specific needs storing
locally. (module_vmware.plain_password is a known wart elsewhere in this
codebase — not repeated here.)
plesk_guid is string(64), not uuid(): Plesk's GUID format isn't
guaranteed to be a canonical RFC-4122 UUID the DB driver's uuid column
type would validate against; storing it as an opaque string avoids a
runtime insert failure on a format mismatch.
service_id is nullable: a hosting account can be provisioned by an admin
ahead of/independent from an order line in Phase 1 (no order/service flow
is being built here), matching how services.vm_id is nullable during
the VM's own pending window.
5. Model
// app/Models/ModuleHostingAccount.php
namespace App\Models;
use App\Traits\LogsActivity;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class ModuleHostingAccount extends Model
{
use HasFactory, LogsActivity;
protected $table = 'module_hosting_accounts';
protected $fillable = [
'customer_id',
'service_id',
'dns_manager_server_id',
'dns_manager_domain_id',
'plesk_client_id',
'plesk_subscription_id',
'plesk_guid',
'hosting_plan',
'ftp_username',
'system_username',
'document_root',
'status',
];
public function customer()
{
return $this->belongsTo(Customer::class);
}
public function service()
{
return $this->belongsTo(Service::class);
}
public function server()
{
return $this->belongsTo(DnsServer::class, 'dns_manager_server_id');
}
public function domain()
{
return $this->belongsTo(DnsDomain::class, 'dns_manager_domain_id');
}
}
DnsServer gets one addition — a typed accessor for type, matching how
DnsDomain/DnsServer already expose small helper methods
(getStatus()):
// App\Models\DnsServer — add
public function servesHosting(): bool
{
return in_array($this->attributes['type'] ?? 'domain', ['hosting', 'both'], true);
}
6. Relationships
customers 1──* module_hosting_accounts
services 1──0..1 module_hosting_accounts (service_id, nullable — same shape as services.vm_id → module_vmware)
dns_manager_servers 1──* module_hosting_accounts (type must include 'hosting' or 'both')
dns_manager_domain 1──0..1 module_hosting_accounts (dns_manager_domain_id, nullable)
Customer
└─ Hosting Account (module_hosting_accounts)
├─ DNS Manager Server (dns_manager_servers, type ∈ {hosting, both})
└─ DNS Domain (dns_manager_domain, optional)
Domain Module link — yes, loosely, and only via dns_manager_domain,
not App\Models\Domain. Reasoning:
App\Models\Domain(registrar-side TLD ownership, the "Domain Management" module in the request) andApp\Models\DnsDomain(dns_manager_domain, a DNS zone actually hosted on one of our Plesk boxes) are different entities in the current schema — a customer can own a registrar domain that has never been pointed at our DNS/Plesk infrastructure, and conversely a DNS zone here doesn't strictly need to correspond to a domain we sold. Nothing in the audited code links them 1:1 today.- Hosting only needs "which DNS zone/vhost name will Plesk serve this
subscription under," which is exactly what
dns_manager_domain_idcaptures. Forcing a link toDomain(registrar) would either require inventing adomains.dns_manager_domain_idbridge that doesn't exist (out of scope, a Domain-module change, not a Hosting-module one) or silently assume every hosting customer's domain is registrar-managed by us, which isn't guaranteed. - Keeping
dns_manager_domain_idnullable also covers the CLI/admin flow of provisioning hosting before any DNS zone exists for it — Plesk's subscription can be created against a domain name string even if no localdns_manager_domainrow is made yet in Phase 1.
7. Repository
app/Repositories/Module/HostingAccount/
HostingAccountRepositoryInterface.php
HostingAccountRepository.php
HostingAccountCachedRepository.php
interface HostingAccountRepositoryInterface extends BaseRepositoryInterface
{
public function filter(
array $data = [],
array $relationships = [],
array $withCount = [],
array $select = ['*'],
bool $returnQuery = false
);
}
HostingAccountRepository::getModel() → ModuleHostingAccount::class,
filter() implemented the same way as HostingRepository::filter()
(status/relations/pagination handling already shown in that file) — copy
that shape, don't invent a new query-building convention.
HostingAccountCachedRepository wraps it exactly like
DnsServerCachedRepository does (Cache::remember on filter, pass-
through when returnQuery/paginate is set).
Also add, on App\Repositories\DnsManager\Server\DnsServerRepositoryInterface
(the interface ServerService already depends on):
public function getUsableFor(ServerType $type): Collection;
// WHERE type = 'both' OR type = :type
Bind both interfaces explicitly in RepositoryProvider:
$this->app->singleton(HostingAccountRepositoryInterface::class, HostingAccountCachedRepository::class);
(And, while touching this file: confirm/fix the DnsManager\Server
binding gap noted in §1.1 — the new Hosting service depends on the same
interface ServerService uses, so if that resolution is currently
implicit it needs to keep working for both consumers, not just happen to
work for the one that existed first.)
8. Service — Plesk integration (Phase 1 only)
app/Services/Plesk/
PleskService.php // shared low-level HTTP: resolves a DnsServer → Http::withBasicAuth(...), used by all the others
PleskClientService.php // find-or-create Plesk customer
PleskDomainService.php // create/update/remove Plesk domain object — EXTRACTED from ServerService, not rewritten
PleskHostingService.php // orchestrates: customer → subscription → plan → persist
PleskService is the one new piece of shared plumbing, and it's a direct
copy of what ServerService already does per-call (Http::withoutVerifying() ->withBasicAuth($server->username, Crypt::decryptString($server->password)) ->timeout(...)), pulled out so ServerService (DNS) and the new
PleskHostingService both call the same code instead of two Basic-Auth
blocks drifting apart:
class PleskService
{
public function client(DnsServer $server): PendingRequest
{
return Http::withoutVerifying()
->withBasicAuth($server->username, Crypt::decryptString($server->password))
->timeout(30);
}
}
PleskDomainService::createDomain() is ServerService::createDomain()
moved verbatim (same hosting_settings/plan payload shape, same
['error'=>bool,'data'=>...] return) — ServerService then delegates to
it instead of duplicating the HTTP call, so DNS-module behavior doesn't
change.
PleskClientService — new for Phase 1, Plesk has no "create domain
without a client" path that fits multi-tenant billing, so this is
required, not optional:
class PleskClientService
{
public function __construct(private PleskService $plesk) {}
public function findOrCreateCustomer(DnsServer $server, Customer $customer): array
{
// GET /clients?login=... ; if 404, POST /clients
// returns ['error' => bool, 'data' => ['id' => ..., 'guid' => ...]]
}
}
PleskHostingService — the Phase 1 entry point:
class PleskHostingService
{
public function __construct(
private PleskClientService $clients,
private PleskDomainService $domains,
private HostingAccountRepositoryInterface $accounts,
) {}
public function provision(array $data): array
{
// $data: customer_id, dns_manager_server_id, dns_manager_domain_id?,
// domain_name, hosting_plan
$server = ...; // DnsServer::findOrFail, must servesHosting()
$client = $this->clients->findOrCreateCustomer($server, $customer);
if ($client['error']) return $client;
$subscription = $this->domains->createDomain($server, [
'domain_name' => $data['domain_name'],
'plesk_client_id' => $client['data']['id'],
'plan' => $data['hosting_plan'],
]);
if ($subscription['error']) return $subscription;
$account = $this->accounts->create([
'customer_id' => $data['customer_id'],
'service_id' => $data['service_id'] ?? null,
'dns_manager_server_id' => $server->id,
'dns_manager_domain_id' => $data['dns_manager_domain_id'] ?? null,
'plesk_client_id' => $client['data']['id'],
'plesk_subscription_id' => $subscription['data']['id'],
'plesk_guid' => $subscription['data']['guid'],
'hosting_plan' => $data['hosting_plan'],
'ftp_username' => $subscription['data']['hosting_settings']['ftp_login'] ?? null,
'system_username' => $subscription['data']['hosting_settings']['ftp_login'] ?? null,
'document_root' => $subscription['data']['www_root'] ?? null,
'status' => 'active',
]);
return ['error' => false, 'data' => $account];
}
}
No suspend()/unsuspend()/resetFtpPassword() bodies are written yet —
the API routes for them exist (§9, per explicit request) but their
controller actions return 501/"not implemented in this phase" until
those methods are built. Writing stub method bodies now that call nothing
would be scaffolding for a phase that hasn't been scoped — the route
existing is enough to reserve the contract.
9. Controller & API
app/Http/Requests/ModuleHosting/CreateHostingAccountRequest.php
app/Http/Requests/ModuleHosting/UpdateHostingAccountRequest.php
app/Http/Resources/Api/Admin/HostingAccountResource.php
app/Http/Controllers/Api/Admin/Hosting/HostingAccountController.php
// routes/api.php, alongside the existing DNS Manager group
Route::prefix('hosting-accounts')
->controller(HostingAccountController::class)
->group(function () {
Route::get('/', 'index')->middleware('perm:hosting.view');
Route::get('{id}', 'show')->middleware('perm:hosting.view');
Route::post('/', 'store')->middleware('perm:hosting.create');
Route::put('{id}', 'update')->middleware('perm:hosting.edit');
Route::delete('{id}', 'destroy')->middleware('perm:hosting.destroy');
Route::post('{id}/suspend', 'suspend')->middleware('perm:hosting.edit');
Route::post('{id}/unsuspend', 'unsuspend')->middleware('perm:hosting.edit');
Route::post('{id}/reset-ftp-password', 'resetFtpPassword')->middleware('perm:hosting.edit');
});
store() calls PleskHostingService::provision() and returns the
HostingAccountResource. suspend/unsuspend/reset-ftp-password
controller methods exist per the request's API list but return
response()->json(['message' => 'Not implemented in this phase'], 501) —
routed and permission-gated now, implemented when their phase lands.
Add to RolesAndPermissions.php seeder:
'hosting.view', 'hosting.create', 'hosting.edit', 'hosting.destroy',
10. Reusable existing code
App\Services\ServerService::createDomain/updateDomain/removeDomain→ extracted intoPleskDomainService, reused by both modules (§8).- Basic-Auth-over-
Http-facade pattern → copied intoPleskServiceexactly as done inServerService/vCenter\Client, no new HTTP layer. Crypt::decryptString($server->password)→ same decryption call reused as-is; no new encryption scheme introduced (explicitly notSignRSA, which is the legacy module's scheme).BaseRepository/BaseCachedRepository/BaseRepositoryInterface→ used unchanged.App\Traits\LogsActivity→ reused onModuleHostingAccount, same asDnsServer/DnsDomain/ModuleHosting.- Migration style, permission-seeding call site, route-group style — all
copied from the DNS Manager section of
routes/api.phpandRolesAndPermissions.php, not reinvented. HostingRepository::filter()(legacy,app/Repositories/Module/Hosting/HostingRepository.php) → its filter/relation/status query shape is copied as the template for the new repository'sfilter(), even though the legacy table itself is not reused.
11. Trade-offs
| Decision | Trade-off accepted |
|---|---|
type as varchar + both value |
One extra IN-expansion needed at every "find a server for X" query site, centralized in getUsableFor(). In exchange: zero new tables, matches the table's existing status column style, and a future 4th type is a one-line enum case instead of an ALTER TABLE ... MODIFY ENUM. |
| No pivot/capability table | Can't express fine-grained per-capability metadata (e.g. "default server for hosting" flag) — not needed by anything requested; if it's needed later it's an additive migration, not a rewrite of this one. |
service_id nullable, not required |
Lets Phase 1 provision hosting without a full order/service pipeline wired up yet; costs a nullable check everywhere the relation is read. |
dns_manager_domain_id nullable, no FK to App\Models\Domain |
Keeps Hosting decoupled from Domain-module assumptions that don't hold today (registrar domain ≠ DNS-managed zone); costs one more nullable relation to explain in onboarding docs. |
No password/Basic-Auth column on module_hosting_accounts |
Every Plesk call for this account has to resolve back through dns_manager_server_id first — no local shortcut. In exchange: one credential source of truth, matches the explicit "don't store it twice" instruction, and avoids repeating module_vmware.plain_password. |
Extracting PleskDomainService out of ServerService rather than writing a second Plesk client |
Touches an existing, working file (small risk) instead of a net-new, isolated one (zero risk to DNS module). In exchange: avoids the exact duplication (module_hosting_server vs dns_manager_servers, two unrelated Plesk-ish clients) that already happened once in this codebase. |
New hosting.* permission group instead of reusing seeded module-hosting.* |
One more permission group to seed. In exchange: doesn't couple a new, modern API to whatever the legacy admin-blade screens currently do with module-hosting.*, and keeps the "flat name = modern services-era module" convention legible. |
| No stub bodies for suspend/unsuspend/reset-ftp-password | Routes exist per the request's API list but 501 until built — avoids writing speculative Plesk-call code for a phase not yet scoped, per explicit instruction to design Phase 1 only. |
12. Explicitly not designed here
Per instruction: Database, SSL, Mailbox, dedicated FTP account creation
beyond Plesk's default system user, Git deploy, source auto-upload,
Let's Encrypt. No schema columns, service methods, or routes for these are
included above, including no nullable "reserved for later" columns on
module_hosting_accounts — adding those now would be exactly the
speculative scaffolding this phase is meant to avoid.