# 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` (model `App\Models\DnsServer`) — the live Plesk credential store: `id, name, ip_address, username, password, api_key, status, timestamps`. No `type`, `hostname`, `port`, `protocol` columns exist today. `dns_manager_domain` (model `DnsDomain`) has `dns_manager_server_id` FK and `plesk_id`. `dns_manager_ns` (model `DnsManagerNS`) has `plesk_id` too. Repository: `App\Repositories\DnsManager\Server\DnsServerRepositoryInterface` → bound in `RepositoryProvider` — **actually check**: this specific interface/binding was *not* found in `RepositoryProvider.php`'s bind list (only `Module\DnsServer\DnsServerRepositoryInterface`, a different, legacy class tied to table `module_dns_server`, was found bound). This means `ServerService` (below) currently resolves `DnsManager\Server\DnsServerRepositoryInterface` from 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}` with `Http::withoutVerifying()->withBasicAuth($username, Crypt::decryptString($password))`. It already implements `createDomain()`, `updateDomain()`, `removeDomain()`, `getDomainInfo()`, `listRecords()`, `checkConnection()`. `createDomain()` already POSTs `hosting_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 (`Http` facade, 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-`services` era, WHMCS-shaped flow. Different crypto (`SignRSA`, not `Crypt::`), different credential table, different (non-Plesk-v2-REST) API surface (`CheckUserClient`, `CreateNewClient`, `CreateNewDomain`, `CreateExpirationForDomain`), stores the account password directly on `module_hosting.password`. **Not reused** — instruction says no legacy tables and no duplicated data; this codebase's own `services`/`products` layer 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`). No `tbl_` 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 the `foreignId()` 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`, one `Schema::create`/`Schema::table` block 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.status` itself is a `string` column with values validated by convention/PHP, not a DB enum. Matching that means `type` should be `string` too — one column-kind convention for the whole table, not a mix. - `App\Enums\Domain\DomainProvisioningStatus` shows the actual "enum" convention in this codebase: a PHP backed enum class used for validation and `label()`/transition logic, with the DB column being a plain `string`. Adding `both` as a third case later, or a `mail`/`cdn` value in some future phase, is then a one-line enum case, not a migration to widen a native `ENUM` definition (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 | both` is a small, stable, mutually-exclusive-enough set for a single column, unlike the earlier revision's "server can have an open-ended combination" framing. ```php 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. ```php // 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 ```php // 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'); }); ``` ```php // 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 ```php // 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()`): ```php // 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) and `App\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_id` captures. Forcing a link to `Domain` (registrar) would either require inventing a `domains.dns_manager_domain_id` bridge 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_id` nullable 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 local `dns_manager_domain` row is made yet in Phase 1. ## 7. Repository ``` app/Repositories/Module/HostingAccount/ HostingAccountRepositoryInterface.php HostingAccountRepository.php HostingAccountCachedRepository.php ``` ```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): ```php public function getUsableFor(ServerType $type): Collection; // WHERE type = 'both' OR type = :type ``` Bind both interfaces explicitly in `RepositoryProvider`: ```php $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: ```php 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: ```php 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: ```php 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 ``` ```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: ```php 'hosting.view', 'hosting.create', 'hosting.edit', 'hosting.destroy', ``` ## 10. Reusable existing code - `App\Services\ServerService::createDomain/updateDomain/removeDomain` → extracted into `PleskDomainService`, reused by both modules (§8). - Basic-Auth-over-`Http`-facade pattern → copied into `PleskService` exactly as done in `ServerService`/`vCenter\Client`, no new HTTP layer. - `Crypt::decryptString($server->password)` → same decryption call reused as-is; no new encryption scheme introduced (explicitly not `SignRSA`, which is the legacy module's scheme). - `BaseRepository`/`BaseCachedRepository`/`BaseRepositoryInterface` → used unchanged. - `App\Traits\LogsActivity` → reused on `ModuleHostingAccount`, same as `DnsServer`/`DnsDomain`/`ModuleHosting`. - Migration style, permission-seeding call site, route-group style — all copied from the DNS Manager section of `routes/api.php` and `RolesAndPermissions.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's `filter()`, 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.