Generates std-out/simple-data-objects DTOs, typed RPC clients, and server procedure contracts from Skir schemas.
Generated PHP uses php-skir/runtime for Skir wire formats and std-out/simple-data-objects for immutable DTO hydration, mapped property names, typed collections, serialization, and validation.
Prerequisites: PHP 8.4 or newer, Composer 2, and a supported Node.js release.
Install Skir and this generator as development dependencies, then install the PHP runtime and DTO library:
npm install --save-dev skir skir-simple-data-objects-generator
composer require php-skir/runtime std-out/simple-data-objectsCreate skir-src/users.skir:
struct User {
user_id: int32;
email: string;
nickname: string?;
}
Add the generator to the root skir.yml:
generators:
- mod: skir-simple-data-objects-generator
outDir: generated/php/skirout
config:
namespace: Skir
validation:
users.skir:
User:
email:
- emailGenerate the PHP files and configure Composer autoloading:
npx skir gen
npx skir-simple-data-objects-generator configure-composer
composer dump-autoloadThe generated Skir\UserData class is now ready to use:
use Skir\UserData;
$user = UserData::makeFromSkirPayload([
'user_id' => 400,
'email' => 'maxim@example.com',
]);
$json = $user->toSkirJson();
$decoded = UserData::fromSkir($json);
$decoded->userId; // 400
$decoded->nickname; // nullThe root namespace defaults to Skir, so config.namespace is optional when that default is suitable.
Skir owns the configured outDir and may replace or delete anything inside it. Use a dedicated path ending in /skirout, do not put handwritten PHP there, and never edit a generated file. Change the .skir source or generator configuration and regenerate instead.
Source directories below skir-src become PHP subnamespaces and output directories. The .skir filename itself does not add a namespace segment:
skir-src/admin/users.skir -> generated/php/skirout/Admin/UserData.php
-> Skir\Admin\UserData
Generated struct and enum class names end in Data. A Skir field such as user_id becomes the PHP property $userId, with #[MapPropertyName('user_id')] preserving the original name for hydration and toArray() output.
Run Skir generation before configure-composer: the command verifies that every configured output directory exists before changing composer.json.
skir-simple-data-objects-generator configure-composer [--root <dir>] [--mod <module>]
In a normal npm project, invoke it through npx:
npx skir-simple-data-objects-generator configure-composerThe command reads skir.yml and composer.json, finds the exact mod: skir-simple-data-objects-generator entry, and adds the configured namespace and outDir as a Composer PSR-4 mapping. It changes only composer.json; run composer dump-autoload afterward.
Use --root <dir> for another project root. Use --mod <module> when the configured module is an exact alternative identifier, such as a local file URL:
npx skir-simple-data-objects-generator configure-composer \
--root ../api \
--mod file:///path/to/skir-simple-data-objects-generator/dist/index.jsAn equivalent existing mapping is left byte-for-byte unchanged. A conflicting mapping, invalid configuration, missing output directory, or path outside the project root fails without modifying composer.json.
Every Skir struct generates a final class extending StdOut\SimpleDataObjects\BaseData. In addition to its readonly constructor properties, it exposes:
| Method | Purpose |
|---|---|
skirType() |
Return the runtime Type descriptor. |
makeFromSkirPayload(array $data) |
Validate raw named Skir data, recursively hydrate nested values, and return the DTO. |
fromSkir(string $json) |
Decode dense Skir JSON and delegate to makeFromSkirPayload(). |
toSkirArray() |
Convert DTO values to the named array representation expected by the runtime. |
toSkir() |
Encode the DTO as the dense PHP array representation. |
toSkirJson() |
Encode the DTO as dense Skir JSON. |
Generated classes inherit the current BaseData APIs, including from(), fromValidated(), validate(), toArray(), toJson(), with(), equals(), diff(), only(), except(), collection(), and lazyCollection().
Use PHP property names with with(), but original Skir names in raw payloads and serialized arrays:
$updated = $user->with(userId: 401);
$updated->userId; // 401
$updated->toArray()['user_id']; // 401
$updated->equals($user); // falseBaseData::from() hydrates without validation. Use makeFromSkirPayload() for raw Skir data: it applies the generated validation contract and performs the Skir-specific recursive conversions before calling BaseData::from().
BaseData::collection(), BaseData::lazyCollection(), and TypedDataCollection::of() are also trusted hydration paths. Like from(), they do not execute generated #[Rules] validation. Construct collections from DTOs or other trusted values:
use Skir\AddressData;
$addresses = AddressData::collection([
AddressData::makeFromSkirPayload([
'city' => 'Antwerp',
'postal_codes' => ['2000'],
]),
]);
foreach ($addresses as $address) {
echo $address->city;
}For untrusted raw item arrays, call AddressData::makeFromSkirPayload() for each item before constructing a collection. When the collection belongs to a generated parent DTO or RPC request, prefer passing the complete raw parent payload through the parent's makeFromSkirPayload() or generated RPC hydration path; those paths validate each nested struct before collection construction.
Only a direct array of structs becomes a TypedDataCollection:
| Skir shape | Generated PHP type |
|---|---|
[Address] |
TypedDataCollection with #[DataCollection(AddressData::class)] |
[Address]? |
?TypedDataCollection with the same collection attribute |
[[Address]] |
array |
[Address?] |
array |
[string] or [SomeEnum] |
array |
Nested arrays and arrays whose item is optional remain normal PHP arrays, although supported nested structs and enum wrappers are still converted recursively.
Skir enums generate readonly wrapper classes with a Data suffix. Constant variants have parameterless named constructors; payload variants have typed named constructors. Generated enum classes expose name(), payload(), skirType(), toSkirValue(), fromSkirValue(), toDenseValue(), fromDenseValue(), toDenseJson(), and fromDenseJson().
Structs, typed struct collections, nested arrays, optionals, and nested enum wrappers are converted to wire values when stored in a wrapper and converted back to target DTO values when payload() is read.
The generator emits #[Rules] attributes understood by Simple Data Objects and Laravel's validator. Rules are deterministic: generated structural rules come first, followed by configured string rules in their exact configured order.
Every non-optional field starts with required. Every optional field starts with nullable, so the key may be omitted or its value may be null. A safely representable shape rule follows when available:
| Skir type | Generated shape rule |
|---|---|
bool |
boolean |
int32, timestamp |
integer |
float32, float64 |
numeric |
string, bytes |
string |
| any array | array |
| struct reference | array |
enum reference, mixed |
none |
int64, hash64 |
none |
int64 and hash64 deliberately receive no narrowing rule because their PHP representation is int|string.
Array validation is structural only at the field level. The generator emits array, but not wildcard item rules such as field.*. Array-shaped nested structs are recursively hydrated through their own makeFromSkirPayload() method and therefore run their own field rules. Put additional element constraints in custom named rules or at a handwritten application boundary.
makeFromSkirPayload() processes input in this order:
- Validate the raw parent array with
self::validate($data). - Convert mapped names and recursively hydrate nested structs, collections, arrays, and enum wrappers. Each nested struct validates its own raw array before hydration.
- Pass the already validated and prepared values to
self::from($payload).
Generated RPC providers use the same raw-to-target conversion for incoming requests, and generated clients use it for responses. Direct struct requests and every recursively hydrated nested struct are validated before application code receives them.
The final BaseData::from() call is intentionally a hydration-only operation and does not validate by itself. Calling SomeData::from($untrustedInput) directly bypasses the generated raw-first validation sequence.
Configure application-specific rules under config.validation in skir.yml:
generators:
- mod: skir-simple-data-objects-generator
outDir: generated/php/skirout
config:
namespace: Skir
validation:
admin/users.skir:
"Envelope.Metadata":
display_name:
- min:2
- max:80
email_address:
- email:rfc
- company_emailSelectors use the original Skir identities, in this order:
- Module path relative to
skir-src, such asadmin/users.skir. - Qualified record name, such as
Envelope.Metadata. - Original Skir field name, such as
display_name, not the generated$displayNameproperty.
Unknown modules, records, or fields fail generation instead of silently dropping a rule. Enum variants, removed fields, and other non-struct payload fields cannot receive validation overlays.
Only non-empty string rules can be encoded in skir.yml. The generator appends them after structural rules without reordering them. Register every custom named rule before generated DTO validation runs.
Laravel's validator is available to BaseData through the application container. Register custom named rules during application boot:
namespace App\Providers;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\ServiceProvider;
final class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
Validator::extend(
'company_email',
static fn (string $attribute, mixed $value): bool =>
is_string($value) && str_ends_with($value, '@company.test'),
);
}
}Register the rule before any generated makeFromSkirPayload(), fromSkir(), or RPC hydration call that may use it.
Standalone applications can create and install an Illuminate validator factory:
use Illuminate\Translation\ArrayLoader;
use Illuminate\Translation\Translator;
use Illuminate\Validation\Factory as ValidatorFactory;
use StdOut\SimpleDataObjects\BaseData;
$validator = new ValidatorFactory(
new Translator(new ArrayLoader(), 'en'),
);
$validator->extend(
'company_email',
static fn (string $attribute, mixed $value): bool =>
is_string($value) && str_ends_with($value, '@company.test'),
);
BaseData::setValidatorFactory($validator);Install the configured factory before the first generated validation call.
Laravel rule objects and closures cannot be represented in skir.yml; only strings are portable generator configuration. Keep request-specific authorization, database-aware rules, uploaded-file rules, closure rules, normalization, and other application behavior in a handwritten Laravel Form Request or equivalent application-owned validation boundary.
That boundary must live outside skirout, because regeneration overwrites generated files. It complements the generated wire-shape and named-rule validation instead of modifying generated DTOs.
When a schema contains methods, the generator emits six module-scoped PHP RPC files plus skir-server-manifest.json:
SkirMethods.phpwith runtimeMethodDescriptorinstances.- A module method enum such as
AdminSkirMethod.php. SkirRpcClient.phpwith typed request and response conversion.SkirProcedures.phpandAbstractSkirProcedures.phpfor implementation contracts.SkirProcedureProvider.phpfor server registration.skir-server-manifest.jsonwith exact module and method identities andData-suffixed struct and enum classes.
Install the client package when using the generated RPC client:
composer require php-skir/clientInstall the server package when using generated procedure contracts or providers:
composer require php-skir/serverRPC methods retain target types at the application boundary. Direct struct collections are accepted and returned as TypedDataCollection; nested collection shapes remain arrays. The generated client converts requests to raw Skir values and hydrates responses. The generated providers validate and hydrate incoming raw values before calling application code, then convert returned DTOs back to Skir values.
This generator emits contracts and conversions only. HTTP transport and server routing remain the responsibility of php-skir/client, php-skir/server, and the application.
Install dependencies and run the complete package verification sequence:
npm ci
npm run typecheck
npm run build
npm run pack:dry-run
SKIR_RUNTIME_PATH=../runtime npm test
git diff --checkThe integration tests require PHP 8.4, Composer, and a local checkout of php-skir/runtime. Pull requests should keep generated output behavior covered by source assertions and executable PHP integration tests.
Build the core first. Before the initial compatible core release and adapter lockfile commit, one no-save install can install this package's development dependencies and satisfy @php-skir/generator-core from the local checkout without creating a lockfile:
cd ../generator-core
npm ci
npm run build
cd ../skir-simple-data-objects-generator
npm install --no-save --package-lock=false ../generator-coreOnce the compatible core package has been published and this repository has committed its normal package-lock.json, use npm ci instead of the local-path bootstrap.