docs/maml.md
MAML is a JSON superset with comments, raw multiline strings, optional commas, unquoted keys,
and ordered objects. Files use the .maml extension.
Recipes are validated on load against
MamlRecipe::schema().
Validation errors point at the offending span with a source snippet.
{
# Import other recipes (php, maml, or yaml).
import: [
"recipe/common.php"
]
config: {
repository: "[email protected]:example/example.com.git"
}
hosts: {
"example.com": {
remote_user: "deployer"
deploy_path: "~/example"
}
}
tasks: {
# Build the project
build: [
{ cd: "{{release_path}}" }
{ run: "npm ci" }
{ run: "npm run build" }
]
}
after: {
"deploy:failed": "deploy:unlock"
}
}
Generate a starter recipe with dep init and pick maml when prompted.
A MAML document is a single value, normally a top-level object { ... }.
# to end of line."..." with standard escapes (\t, \n, \r, \", \\, \u{XXXX})."""...""", no escapes, newlines preserved. Use for embedded scripts.5, -3) and floats (1.5, 1e9).true, false, null.[ ... ], comma- or newline-separated.{ key: value }, comma- or newline-separated. Keys are unquoted identifiers (letters, digits, _,
-) or quoted strings. Quote keys with dots ("example.com") and colons ("deploy:failed").Trailing commas allowed. Duplicate keys are not.
A recipe is an object with these optional keys, validated by the schema:
| Key | Description |
|---|---|
import | String or array of strings. Paths to other recipes (.php, .maml, .yaml). |
config | Object. Becomes calls to set(). |
hosts | Object. Each entry becomes host() (or localhost() when local: true). |
tasks | Object. Each entry becomes a task(). |
before | Object mapping task → hook(s). Becomes before(). |
after | Object mapping task → hook(s). Becomes after(). |
fail | Object mapping task → fallback task. Becomes fail(). |
Any other top-level key is rejected with a schema error.
importPull in other recipes. .php files are required; .maml and .yaml files are parsed and applied. Use
imports to bring custom PHP tasks, callbacks, or helpers into a MAML recipe.
{
import: "recipe/laravel.php"
}
{
import: [
"recipe/common.php"
"deploy/custom.php"
"deploy/extras.maml"
]
}
Built-in recipe/* and contrib/* paths resolve via PHP's include path — no need for __DIR__ or absolute
paths. See import().
configEach key calls set($key, $value). Values can be any MAML type — string, number, bool, array, or nested object.
{
config: {
repository: "[email protected]:example/example.com.git"
keep_releases: 5
ssh_multiplexing: true
shared_dirs: ["storage", "bootstrap/cache"]
}
}
config does not accept PHP closures. For runtime-evaluated values, import a .php recipe and set() from
there.
hostsEach entry calls host(). Quote keys with dots. Every nested key/value is forwarded to Host::set(), so all
standard host options work: remote_user, deploy_path, port, identity_file, labels, ssh_arguments, etc.
{
hosts: {
"prod.example.com": {
remote_user: "deployer"
deploy_path: "/var/www/prod"
labels: { stage: "production" }
}
"staging.example.com": {
remote_user: "deployer"
deploy_path: "/var/www/staging"
labels: { stage: "staging" }
}
}
}
Labels are key-value tags used by selectors. Define them as a nested object under labels:
{
hosts: {
"web.example.com": {
remote_user: "deployer"
labels: {
type: "web"
env: "prod"
}
}
"db.example.com": {
remote_user: "deployer"
labels: {
type: "db"
env: "prod"
}
}
}
}
Run a task on every prod host:
$ dep deploy env=prod
labels.<key> and a top-level config key with the same name (e.g. env) are independent — the selector only
looks at labels.
Set local: true to register the entry as a localhost via localhost():
{
hosts: {
"dev": {
local: true
deploy_path: "/tmp/dev"
}
}
}
tasksA task entry is either:
{
tasks: {
deploy: [
"deploy:prepare"
"deploy:vendors"
"deploy:publish"
]
}
}
Each step is an object with exactly one action key (cd, run, runLocally, upload, download) or one
task-config key (desc, once, hidden, limit, select). Steps run in declaration order. Config-only steps
adjust task metadata and do not break the action chain.
{
tasks: {
build: [
{ desc: "Build assets" }
{ once: true }
{ cd: "{{release_path}}" }
{ run: "npm ci" }
{ run: "npm run build" }
]
}
}
# comments directly above a task key become its description (joined with newlines). A desc step takes
precedence if both are present.
{
tasks: {
# Deploy the application
# Runs migrations, builds assets, restarts services
deploy: [
{ run: "echo deploying" }
]
}
}
Use these step keys to control task metadata. They mirror the chained methods in Tasks.
| Key | Type | Effect |
|---|---|---|
desc | string | Sets the description (shown in dep list). |
once | bool | Run on a single host only. |
hidden | bool | Hide from dep list. |
limit | number | Maximum hosts to run on in parallel. |
select | string | Host selector expression (see Selector). |
{
tasks: {
migrate: [
{ desc: "Run database migrations" }
{ once: true }
{ limit: 1 }
{ select: "stage=production" }
{ run: "php artisan migrate --force" }
]
}
}
Each action mirrors the PHP function it is named after.
cdChange the working directory for subsequent run steps in the same task. See cd().
{ cd: "{{release_path}}" }
runRun a command on the remote host. See run().
{
run: "php artisan migrate --force"
cwd: "{{release_path}}"
env: { APP_ENV: "production" }
secrets: { DB_PASSWORD: "s3cret" }
timeout: 600
idleTimeout: 120
nothrow: false
forceOutput: true
}
| Key | Type | Default |
|---|---|---|
cwd | string | {{working_path}} |
cd | string | alias of cwd |
env | object | none |
secrets | object | none |
timeout | seconds | 300 |
idleTimeout | seconds | none |
nothrow | bool | false |
forceOutput | bool | false |
Multiline commands work nicely with raw strings:
{
run: """
set -e
php artisan down
php artisan migrate --force
php artisan up
"""
}
runLocallyRun a command on the local machine. See runLocally(). Same options as run plus shell,
minus cd (use cwd).
{
runLocally: "git rev-parse HEAD"
cwd: "."
shell: "/bin/bash"
timeout: 60
}
uploadSend files to the host. See upload(). src may be a string or array.
{
upload: {
src: "build/"
dest: "{{release_path}}/public/"
}
}
{
upload: {
src: ["dist/app.js", "dist/app.css"]
dest: "{{release_path}}/public/assets/"
}
}
downloadPull files from the host. See download().
{
download: {
src: "{{deploy_path}}/shared/.env"
dest: ".env.production"
}
}
before, after, failAttach hooks to tasks. The value is a task name or an array of names. Quote names with :.
{
before: {
deploy: ["deploy:prepare", "build"]
}
after: {
"deploy:failed": "deploy:unlock"
deploy: "deploy:cleanup"
}
fail: {
deploy: "deploy:rollback"
}
}
Arrays attach in declaration order.
MAML covers declarative parts: config, hosts, step tasks, hooks. Anything that needs runtime PHP — closures,
set('var', fn () => ...), custom step types, conditional logic — belongs in a .php recipe and gets imported
both ways.
From PHP, import a MAML recipe:
import('deploy.maml');
From MAML, list the PHP file under import:
{
import: ["deploy/extras.php"]
}
YAML works the same — see YAML.
A recipe that violates the schema raises a SchemaException pointing at the offending span. Common causes:
config: "string" instead of an object, or tasks: [...] instead of an object.