The PHP DSL
The DSL is the fluent PHP API for declaring a domain. It is a thin builder over the metadata graph node types — a DSL plugin and an equivalent hand-written descriptor plugin compile to a byte-identical graph hash.
This page is the builder reference. For the concepts behind it, see Core Concepts. A condensed cheat sheet is in Reference → DSL.
DslPlugin
Extend DslPlugin and implement three methods:
use Ausus\{DslPlugin, Dsl};
final class BillingPlugin extends DslPlugin
{
public function name(): string { return 'billing'; }
public function phpNamespace(): string { return 'Acme\\Billing'; }
public function dsl(Dsl $dsl): void
{
// declare entities here
}
}
Dsl — declaring an entity
The Dsl builder has one entry point, entity():
$dsl->entity('invoice') // -> EntityBuilder for 'billing.invoice'
The local name is prefixed with the plugin name to form the entity FQN.
EntityBuilder
EntityBuilder is fluent — chain the calls:
| Method | Purpose |
|---|---|
->fields([...]) | declare domain fields (name => FieldBuilder) |
->actions([...]) | declare actions (name => ActionBuilder) |
->workflow(field:, initial:) | declare the workflow: the enum state field and its initial state |
->projection($name, fields:, actions:, role:) | declare a read projection |
$dsl->entity('invoice')
->fields([ /* ... */ ])
->actions([ /* ... */ ])
->workflow(field: 'status', initial: 'DRAFT')
->projection('summary', fields: ['id', 'number', 'status'], role: 'invoice.viewer');
Field — field builders
Field is a static facade returning a FieldBuilder:
| Constructor | Field type |
|---|---|
Field::string() | string |
Field::integer() | integer |
Field::datetime() | datetime |
Field::money() | money |
Field::enum('A', 'B', ...) | enum with the given options |
FieldBuilder modifiers
| Method | Effect |
|---|---|
->nullable() | the field may be null |
->default($value) | a default value |
->unique() | recorded as unique-within-tenant (see limitations) |
->max($n) | string max length |
->currency($code) | money currency code |
->options([...]) | enum options (usually set via Field::enum(...)) |
->label($text) | human-friendly label for table headers / form fields (overrides the auto-humanised default) |
'number' => Field::string()->unique()->max(32),
'amount' => Field::money()->currency('USD'),
'status' => Field::enum('DRAFT', 'ISSUED')->default('DRAFT'),
'issued_at' => Field::datetime()->nullable(),
'project_id' => Field::string()->max(26)->label('Project'), // header reads "Project", not "Project id"
Action::create / Action::update / Action::transition
| Builder | Action kind | Purpose |
|---|---|---|
Action::create(...$inputs) | create | bring a new entity into existence; inputs declare the create payload |
Action::update(...$fields) | update | partial-PATCH a fixed closed list of fields on an existing entity |
Action::transition($field, from:, to:) | transition | move the workflow state field along a declared from → to arrow |
Action::update(...) was added in v0.2 (ADR-0002). Compile-time rules:
- Every named field must exist on the entity.
- System fields (
id,tenant_id,_version,created_at,updated_at) cannot be patched. - The workflow state field — the one passed to
->workflow(field:, ...)— cannot be patched by an update action. State moves remain exclusively the job ofAction::transition(...).
'rename' => Action::update('title')
->requireRole('issue.maintainer'),
'reassign' => Action::update('assignee')
->requireRole('issue.maintainer'),
'edit' => Action::update('title', 'assignee', 'priority')
->requireRole('issue.maintainer'),
The label is cosmetic only — the field name (project_id above) remains
the source of truth for persistence, projection field lookups, action input
keys, and HTTP payloads. Without ->label(), the runtime auto-humanises the
name (project_id → "Project id"); the explicit form is purely an opt-in
override.
You declare only domain fields. The five system fields are injected by the kernel.
Action — action builders
Action is a static facade returning an ActionBuilder:
| Constructor | Action kind |
|---|---|
Action::create('field', ...) | a create action; arguments are input field names |
Action::transition('field', from:, to:) | a transition action |
ActionBuilder modifiers
| Method | Effect |
|---|---|
->requireRole($role) | attaches a RoleRequired policy for $role |
->stamp($field) | (transition) writes the current timestamp to $field |
->andTransition($field, from:, to:) | (transition) adds another source→target pair |
'create' => Action::create('number', 'customer_name', 'amount')
->requireRole('invoice.creator'),
'issue' => Action::transition('status', from: 'DRAFT', to: 'ISSUED')
->stamp('issued_at')
->requireRole('invoice.issuer'),
'cancel' => Action::transition('status', from: 'DRAFT', to: 'CANCELLED')
->andTransition('status', from: 'ISSUED', to: 'CANCELLED')
->requireRole('invoice.canceler'),
:::note andTransition, not transition
The instance method is named andTransition() because transition() is the
static constructor on the same class — PHP does not allow a static and an
instance method to share a name.
:::
Projections
->projection(
'summary', // local name
fields: ['id', 'number', 'customer_name'], // field names to expose
actions: ['create', 'cancel'], // action local names (default: all)
role: 'invoice.viewer', // optional read policy role
)
If actions is omitted the projection exposes all of the entity's actions.
Current v0.1.0 limitations
The DSL is the minimal RFC-011 subset. Deferred to later versions:
- Convention-resolved policy and effect classes — v0.1.0 uses explicit
built-ins (
RoleRequired,kernel.builtin.create,kernel.builtin.transition). - Field-level visibility policies.
- DSL diagnostics with file/line attribution.
- Tenant-added override registration.
->unique()and->max()are stored in the graph but not enforced as runtime validation in v0.1.0.
Related
- Reference → DSL — the condensed cheat sheet.
- Plugins — what a DSL plugin is.
- The Metadata Graph — what the DSL compiles to.