Spec: Plan/Apply Workflow
Issue: MAR-36 Date: 2026-04-23 Status: In Review
Overview
The Plan/Apply workflow is the core operational pattern in Shipper. It separates the intent (planning) from the action (execution), giving operators a safe dry-run phase before any infrastructure is modified. This pattern is implemented consistently across deployment, destruction, and configuration validation flows. Each flow follows a load -> validate -> plan -> execute pipeline with clear error reporting at each stage.
Current Implementation
Key Classes / Files
| File | Class | Responsibility |
|---|---|---|
app/Flows/PlanDeploymentFlow.php |
PlanDeploymentFlow |
Validates config and produces a deployment plan |
app/Flows/ApplyDeploymentFlow.php |
ApplyDeploymentFlow |
Validates config, produces plan, and optionally executes deployment |
app/Flows/ValidateConfigurationFlow.php |
ValidateConfigurationFlow |
Validates all projects and profiles in a config file |
app/Flows/DestroyDeploymentFlow.php |
DestroyDeploymentFlow |
Validates config, produces destruction plan, and executes destruction |
app/Commands/Concerns/FormatsDeploymentPlan.php |
FormatsDeploymentPlan |
Trait: safely renders plan values for command output |
Functional Requirements
FR-001 — Plan Deployment As an operator, I want to preview a deployment without executing it so that I can verify the plan is correct before committing.
- Acceptance:
PlanDeploymentFlow::handle()returns a structured result containing the plan array, project and profile objects, success flag, errors list, and error message. It loads config, validates project/profile existence, validates configuration, and generates a plan.
FR-002 — Apply Deployment As an operator, I want to execute a deployment after planning so that my site is actually provisioned.
- Acceptance:
ApplyDeploymentFlowhas ahandle()method for planning (returns plan + objects) and anexecute()method for running the deployment (returns success, logs, error message). The two-step design allows the command layer to show the plan and confirm before callingexecute().
FR-003 — Destroy Deployment As an operator, I want to destroy a deployed site so that I can cleanly remove infrastructure.
- Acceptance:
DestroyDeploymentFlowmirrors the apply pattern withhandle()(plan) andexecute()(destroy). It usesDestroySiteActioninstead ofExecuteDeploymentAction.
FR-004 — Validate Configuration As an operator, I want to validate an entire config file so that I can catch errors across all projects before attempting any deployment.
- Acceptance:
ValidateConfigurationFlow::handle()iterates all projects and profiles, runsValidateProjectActionfor each combination, and returns a structured error map grouped by project and profile name.
FR-005 — Confirm Before Execution As Shipper, I want to require operator confirmation before executing a destructive action so that accidental runs are prevented.
- Acceptance: Commands (
apply,destroy) callconfirm()unless--forceis passed. Confirmation happens after the plan is displayed but beforeexecute()is called.
Flow Architecture
Common Pipeline (PlanDeploymentFlow)
LoadConfiguration → ValidateProject → CreateDeploymentPlan → Return Result
Apply Flow (ApplyDeploymentFlow)
handle(): LoadConfiguration → ValidateProject → CreateDeploymentPlan → Return (plan + objects for command layer)
execute(): ExecuteDeploymentAction → GetDeploymentLogs → Return (success, logs, error)
Destroy Flow (DestroyDeploymentFlow)
handle(): LoadConfiguration → ValidateProject → CreateDeploymentPlan → Return (plan + objects for command layer)
execute(): DestroySiteAction → Return (success, error)
Validate Flow (ValidateConfigurationFlow)
LoadConfiguration → For each project: For each profile: ValidateProject → Collect errors → Return (success, allErrors)
Data Contracts
PlanDeploymentFlow
final class PlanDeploymentFlow
{
/**
* @return array{
* success: bool,
* project: ProjectConfig|null,
* profile: ProfileConfig|null,
* plan: array<string, mixed>,
* errors: array<int, string>,
* error_message: string
* }
*/
public function handle(string $configPath, string $projectName, string $profileName): array;
}
ApplyDeploymentFlow
final class ApplyDeploymentFlow
{
public function __construct(
private readonly ?\Closure $providerResolver = null,
) {}
/**
* @return array{
* success: bool,
* project: ProjectConfig|null,
* profile: ProfileConfig|null,
* plan: array<string, mixed>,
* errors: array<int, string>,
* error_message: string,
* provider: DeploymentProviderInterface|null
* }
*/
public function handle(string $configPath, string $projectName, string $profileName): array;
/**
* @param array<string, mixed> $plan
* @return array{
* success: bool,
* logs: array<int, string>,
* error_message: string
* }
*/
public function execute(
DeploymentProviderInterface $provider,
ProjectConfig $project,
ProfileConfig $profile,
array $plan,
): array;
}
Note: The providerResolver closure allows test injection of a mock provider. When null, a ProviderFactory is used.
DestroyDeploymentFlow
final class DestroyDeploymentFlow
{
public function __construct(
private readonly ?\Closure $providerResolver = null,
) {}
/**
* @return array{
* success: bool,
* project: ProjectConfig|null,
* profile: ProfileConfig|null,
* plan: array<string, mixed>,
* errors: array<int, string>,
* error_message: string,
* provider: DeploymentProviderInterface|null
* }
*/
public function handle(string $configPath, string $projectName, string $profileName): array;
/**
* @return array{success: bool, error_message: string}
*/
public function execute(
DeploymentProviderInterface $provider,
ProjectConfig $project,
ProfileConfig $profile,
): array;
}
ValidateConfigurationFlow
final class ValidateConfigurationFlow
{
/**
* @return array{
* success: bool,
* errors: array<string, array<string, array<int, string>>>
* }
*/
public function handle(string $configPath): array;
}
Return shape: errors[projectName][profileName][] = errorString
Special key _provider is used for provider-level errors (e.g., missing API key).
FormatsDeploymentPlan trait
trait FormatsDeploymentPlan
{
private function getPlanValue(array $plan, string $key, string $default = 'unknown'): string;
}
Error Handling Patterns
All flows use a consistent result shape with success: bool, errors: array, and error_message: string. Missing project or profile returns success: false with a human-readable error_message. Validation failures return success: false with an errors array containing the validation messages.
Edge Cases
- Project not found: Flows return
success: falsewitherror_message: "Project not found: <name>".projectisnullin the result. - Profile not found: Flows return
success: falsewitherror_message: "Profile not found: <name>".profileisnullin the result. - Configuration validation errors: Flows return
success: falsewith populatederrorsarray anderror_message: "Configuration validation failed".providermay still be returned so the command layer can display provider-specific context. - Provider factory throws
InvalidArgumentException: Caught by the flow and stored underprojectErrors['_provider']. This surfaces provider configuration issues (e.g., missing API key) at the flow level. - execute() called on a failed plan: The command layer only calls
execute()after confirmingsuccess: truefromhandle(). No explicit guard exists in the flow itself. - No log reader available:
execute()continues without logs; the result will havelogs: []. No error is raised.
Acceptance Criteria
-
PlanDeploymentFlow::handle()returns a result for every code path (project not found, profile not found, validation error, success) -
ApplyDeploymentFlowseparateshandle()(planning) fromexecute()(execution) -
ApplyDeploymentFlow::execute()returns deployment logs from the provider's log reader if available -
DestroyDeploymentFlowmirrors the apply pattern with separatehandle()andexecute()methods -
ValidateConfigurationFlow::handle()validates all projects and profiles and returns a grouped error structure - All flows use the same
ProviderFactoryto create providers from config -
ApplyDeploymentFlowandDestroyDeploymentFlowaccept an optionalproviderResolverclosure for testability - Plan output includes provider, project, profile, branch, path, server_id, domain when available
- Commands use
FormatsDeploymentPlan::getPlanValue()to safely render plan values in output
Open Questions / Potential Concerns
PlanDeploymentFlowhas noproviderResolverinjection unlikeApplyDeploymentFlowandDestroyDeploymentFlow. This means it cannot be easily mocked in tests. This may be intentional (plan is read-only) but is worth noting.- The plan array has no formal schema — it is provider-dependent. Commands display known keys but silently skip unknown ones. A JSON schema for the plan structure would improve type safety.
execute()in ApplyDeploymentFlow readsplan['server_id']using assertions to convert to int. Ifserver_idis a non-numeric string, this could throw. The assertion\assert(\is_int($serverIdValue) || \is_string($serverIdValue) || \is_numeric($serverIdValue))followed byis_int() ? $serverIdValue : (int) $serverIdValueis safe, but ifserver_idis absent and defaults to 0, the site context will have siteId=0 and logs will be empty.DestroySiteActionsource was not in the provided file list for this spec. Verify it exists and has a compatible interface withDestroyDeploymentFlow::execute().