Spec: Configuration System
Issue: MAR-18 Date: 2026-04-23 Status: In Review
Overview
The configuration system parses a single shipper.yml file into a strongly-typed PHP object graph that the rest of the application uses for all deployment decisions. It converts flat YAML into a hierarchy of immutable value objects (one per resource type), performs environment-variable interpolation at parse time, and exposes the result via a thin LoadConfigurationAction entry point. Validation of provider credentials and profile completeness happens in a separate ValidateConfigurationFlow that operates on the already-parsed config graph.
Current Implementation
Key Classes / Files
| Class / File | Role |
|---|---|
app/Config/ConfigLoader.php |
Reads YAML file, interpolates env vars, constructs the full object graph |
app/Config/ShipperConfig.php |
Root config object — holds projects and provider credentials |
app/Config/ProjectConfig.php |
Per-project settings: provider, path, profiles, databases, queues, etc. |
app/Config/ProfileConfig.php |
Per-environment settings: branch, domain, aliases, environment vars |
app/Config/DatabaseConfig.php |
Database name, user, and type |
app/Config/EnvironmentConfig.php |
Key-value env var map with merge support |
app/Config/QueueConfig.php |
Queue worker settings (connection, queue name, limits) |
app/Config/CronConfig.php |
Cron job: command, frequency, user |
app/Config/DaemonConfig.php |
Background process: command, user, processes, directory |
app/Config/NetworkRuleConfig.php |
Firewall rule: port, protocol, allow/deny, source IP |
app/Config/RedirectConfig.php |
HTTP redirect: from path, to path, type (redirect/permanent) |
app/Config/SslConfig.php |
SSL: enabled flag and type (letsencrypt) |
app/Actions/LoadConfigurationAction.php |
Thin action that instantiates ConfigLoader and calls load() |
app/Flows/ValidateConfigurationFlow.php |
Iterates all projects/profiles and runs ValidateProjectAction per profile |
shipper.yml |
Reference configuration with two example projects (api, frontend) |
Functional Requirements
FR-001 — File loading
ConfigLoader MUST accept a file path (default: shipper.yml) and MUST throw \RuntimeException if the file does not exist or cannot be read.
FR-002 — YAML parsing
The loader MUST parse the file using symfony/yaml. The parsed result MUST be treated as array<string, mixed> at the top level.
FR-003 — Provider block
The providers top-level key MUST be treated as a freeform array<string, mixed>. Provider data MUST have environment variable interpolation applied before being passed to ShipperConfig. No schema validation is performed on provider values at parse time — this is deferred to ProviderFactory.
FR-004 — Project enumeration
Each key under projects MUST produce one ProjectConfig. Projects with non-string keys or non-array values MUST be silently skipped.
FR-005 — Profile enumeration
Each key under a project's profiles map MUST produce one ProfileConfig. Non-string or non-array entries MUST be silently skipped.
FR-006 — Database parsing
Each entry under databases MUST produce a DatabaseConfig. The name field defaults to the YAML key if absent; user defaults to the YAML key; type defaults to 'mysql'. Invalid types are coerced to their defaults rather than rejected.
FR-007 — Queue parsing
Each entry under queues MUST produce a QueueConfig. Defaults: connection='database', queue='default', max_seconds=60, sleep=30, processes=1, max_tries=1. Non-integer numeric fields are coerced to their defaults.
FR-008 — Cron parsing
Each entry under cron MUST be skipped if either command or frequency is empty or missing. Default user is 'ploi'.
FR-009 — Daemon parsing
Each entry under daemons MUST be skipped if command is empty or missing. Default user is 'ploi', processes defaults to 1, directory defaults to ''.
FR-010 — Redirect parsing
Each entry under redirects MUST be skipped if from or to is empty or missing. Default type is 'redirect'.
FR-011 — Network rule parsing
Each entry under network_rules MUST be skipped if port is not a positive integer. Default type is 'tcp', default rule_type is 'allow'. from_ip is optional and nullable.
FR-012 — SSL parsing
The ssl block is optional. If absent, SslConfig defaults to enabled=false, type='letsencrypt'. Boolean values for enabled that are not actual PHP booleans are coerced to false.
FR-013 — Environment variable config
The environment block at project level and at profile level MUST be parsed into an EnvironmentConfig. Keys MUST be strings; values MAY be string, int, float, or bool — all are cast to string. Non-conforming entries are silently dropped.
FR-014 — Environment variable merging
EnvironmentConfig::mergeWith() MUST return a new EnvironmentConfig where the other's values override matching keys from the base. The original instances MUST NOT be mutated.
FR-015 — Load action
LoadConfigurationAction::handle(string $configPath): ShipperConfig MUST be the public entry point used by commands. It creates a ConfigLoader and calls load().
FR-016 — Validation flow
ValidateConfigurationFlow::handle(string $configPath) MUST return array{success: bool, errors: array<string, array<string, array<int, string>>>}. It loads config, instantiates ProviderFactory, iterates every project and profile, and accumulates errors per project and profile name. Provider instantiation errors are keyed as '_provider'.
FR-017 — Immutability
All config value objects MUST use readonly constructor promotion. No public setters exist. All classes MUST be marked final.
Configuration Interface
providers:
ploi:
api_key: "${PLOI_API_KEY}"
api_url: "https://ploi.io/api"
server_id: "105556"
deployment_timeout: 60
projects:
api:
provider: ploi
path: ./examples/api
repository:
provider: github
name: org/repo
php_version: "8.3"
web_directory: /public
project_root: /
ssl:
enabled: true
type: letsencrypt
deploy_script: |
cd /home/ploi/{site}
git pull origin {branch}
environment:
APP_NAME: "My App"
databases:
main:
name: "app_${PROJECT_NAME}_${PROFILE}"
user: "app_${PROJECT_NAME}_${PROFILE}"
type: mysql
queues:
default:
connection: database
queue: default
max_seconds: 60
sleep: 30
processes: 1
max_tries: 1
cron:
scheduler:
command: "php artisan schedule:run"
frequency: "* * * * *"
user: ploi
daemons:
horizon:
command: "php artisan horizon"
user: ploi
processes: 1
directory: ""
redirects:
old-to-new:
from: /old
to: /new
type: redirect
network_rules:
allow-redis:
name: "Allow Redis"
port: 6379
type: tcp
rule_type: allow
from_ip: "10.0.0.0/8"
profiles:
production:
branch: main
domain: api.example.com
aliases:
- www.api.example.com
environment:
APP_ENV: production
preview:
branch: "${GITHUB_HEAD_REF}"
domain: "api-preview-${GITHUB_PR_NUMBER}.example.com"
environment:
APP_ENV: staging
Data Contracts
final class ShipperConfig
{
public function __construct(
private readonly array $projects, // array<string, ProjectConfig>
private readonly array $providers, // array<string, mixed>
) {}
public function projects(): array {}
public function providers(): array {}
public function getProject(string $name): ?ProjectConfig {}
}
final class ProjectConfig
{
public function __construct(
private readonly string $name,
private readonly string $provider,
private readonly string $path,
private readonly array $profiles, // array<string, ProfileConfig>
private readonly array $repository = [],
private readonly string $webDirectory = '/public',
private readonly string $projectRoot = '/',
private readonly array $databases = [], // array<string, DatabaseConfig>
private readonly SslConfig $ssl = new SslConfig,
private readonly EnvironmentConfig $environment = new EnvironmentConfig,
private readonly string $deployScript = '',
private readonly array $queues = [], // array<string, QueueConfig>
private readonly array $cron = [], // array<string, CronConfig>
private readonly array $redirects = [], // array<string, RedirectConfig>
private readonly string $phpVersion = '',
private readonly string $nginxConfig = '',
private readonly array $daemons = [], // array<string, DaemonConfig>
private readonly array $networkRules = [], // array<string, NetworkRuleConfig>
) {}
// Getters: name(), provider(), path(), profiles(), getProfile(string), repository(),
// webDirectory(), projectRoot(), databases(), getDatabase(string),
// ssl(), environment(), deployScript(), queues(), getQueue(string),
// cron(), getCron(string), redirects(), getRedirect(string),
// phpVersion(), nginxConfig(), daemons(), getDaemon(string),
// networkRules(), getNetworkRule(string)
}
final class ProfileConfig
{
public function __construct(
private readonly string $name,
private readonly string $branch,
private readonly array $config, // array<string, mixed> (raw profile data)
private readonly EnvironmentConfig $environment = new EnvironmentConfig,
) {}
public function name(): string {}
public function branch(): string {}
public function config(): array {}
public function get(string $key, mixed $default = null): mixed {}
public function environment(): EnvironmentConfig {}
public function aliases(): array {} // array<int, string>
}
final class DatabaseConfig
{
public function __construct(
private readonly string $name,
private readonly string $user,
private readonly string $type = 'mysql',
) {}
}
final class EnvironmentConfig
{
public function __construct(private readonly array $variables = []) {} // array<string, string>
public function variables(): array {}
public function isEmpty(): bool {}
public function mergeWith(self $other): self {}
}
final class QueueConfig
{
public function __construct(
private readonly string $connection = 'database',
private readonly string $queue = 'default',
private readonly int $maxSeconds = 60,
private readonly int $sleep = 30,
private readonly int $processes = 1,
private readonly int $maxTries = 1,
) {}
}
final class CronConfig
{
public function __construct(
private readonly string $command,
private readonly string $frequency,
private readonly string $user = 'ploi',
) {}
}
final class DaemonConfig
{
public function __construct(
private readonly string $command,
private readonly string $user = 'ploi',
private readonly int $processes = 1,
private readonly string $directory = '',
) {}
}
final class NetworkRuleConfig
{
public function __construct(
private readonly string $name,
private readonly int $port,
private readonly string $type = 'tcp',
private readonly string $ruleType = 'allow',
private readonly ?string $fromIp = null,
) {}
}
final class RedirectConfig
{
public function __construct(
private readonly string $from,
private readonly string $to,
private readonly string $type = 'redirect',
) {}
}
final class SslConfig
{
public function __construct(
private readonly bool $enabled = false,
private readonly string $type = 'letsencrypt',
) {}
}
Edge Cases
- Missing config file:
ConfigLoaderthrows\RuntimeExceptionimmediately, no partial state. - Non-string project/profile keys: Silently skipped — the YAML map key must be a string.
- Unresolvable env var in YAML (ConfigLoader path): The
${VAR}token is left as-is in the string (not replaced with empty string). This is different from theInterpolatesNamestrait used during deployment, which replaces unresolved vars with''. - Non-array
providersblock: Treated as empty array, no error raised. - Invalid
enabledtype for SSL: Non-boolean values coerce tofalse. - Cron/daemon with empty command: Entry silently dropped from the parsed map.
- Network rule with port 0 or negative: Entry silently dropped (only
$rulePort > 0passes). - Environment values that are booleans: Cast to
string— PHP's(string) trueis'1'and(string) falseis''. This may produce unexpected.envvalues. - Profile raw config array:
ProfileConfigstores the entire raw profile array including unrecognized keys, accessible viaget(key). There is no strict allowlist of profile fields.
Acceptance Criteria
- Loading
shipper.ymlreturns aShipperConfigwith the correct project and provider counts - A missing config file causes
LoadConfigurationActionto throw\RuntimeException -
ProjectConfigcorrectly exposes all typed sub-configs (databases, queues, cron, daemons, redirects, network rules, ssl, environment) -
EnvironmentConfig::mergeWith()merges keys with the other taking precedence and does not mutate the originals - All config classes are
finaland usereadonlyconstructor promotion - Cron entries without a command or frequency are absent from the parsed map
- Daemon entries without a command are absent from the parsed map
- Redirect entries without from/to are absent from the parsed map
- Network rules with port <= 0 are absent from the parsed map
-
ValidateConfigurationFlowreturnssuccess: falseand populateserrorswhen provider instantiation fails
Open Questions / Potential Concerns
- Default coercion vs. rejection: When a YAML field has the wrong type (e.g.,
max_seconds: "sixty"), the current code silently falls back to the default value. This means misconfigured YAML produces no warning — the user may not notice. Consider adding a validation pass that surfaces type mismatches. - Boolean env values casting:
(string) falseis''in PHP, which would set an env var to an empty string. This may be unexpected for values likeAPP_DEBUG: false. It is worth deciding whetherfalseshould become'false'explicitly. ProfileConfigholds the entire raw array: Consumers can call$profile->get('any_key'), meaning there is no schema enforcement on profiles beyondbranchandenvironment. Undocumented keys are silently carried through.nginx_configfield: Accepted inProjectConfigbut there is no test coverage for it and no documentation inCONFIGURATION.md. It is unclear whether this is a planned or partially-implemented feature.deploy_scriptat profile level: The referenceshipper.ymlshows a profile-leveldeploy_script(e.g., underproduction), butProfileConfigstores the raw config array rather than a dedicated typed field. Consumers accessing the profile deploy script would need to call$profile->get('deploy_script'). This is inconsistent with how the project-level deploy script is exposed as a typed getter.aliasesin ProfileConfig: Parsed from the raw config array viaaliases()— not a constructor parameter. No test coverage for this method.