Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

SchemaScript (.scsc) is a structured, block-based schema definition language. You define your data models once – with typed properties, annotations, generics, inheritance, and mapping rules – and a PHP toolchain compiles them into language-specific output: TypeScript interfaces, PHP mapper classes, and more.

Why SchemaScript?

Most projects that communicate across language boundaries end up maintaining parallel type definitions: TypeScript interfaces for the frontend, PHP arrays or DTOs for the backend, maybe a database migration layer on top. These inevitably drift apart. A renamed field in one place becomes a silent bug in another.

SchemaScript eliminates this by making the schema the single source of truth. You describe your data structures in .scsc files, and the toolchain generates type-safe code for each target language – with automatic property name mapping, type casting, and null safety built in.

One schema, many outputs:

┌─────────────┐
│  .scsc file  │
└──────┬──────┘
       │
       ▼
┌─────────────┐     ┌────────────────────┐
│   Compiler   │────▶│  TypeScript types   │  User.ts, _types.ts
└──────┬──────┘     └────────────────────┘
       │            ┌────────────────────┐
       ├───────────▶│   PHP Mappers       │  UserMapper.php
       │            └────────────────────┘
       │            ┌────────────────────┐
       └───────────▶│   PHP SAMG Maps     │  UserMap.php
                    └────────────────────┘

Key Features

  • Typed properties with integers, floats, strings, booleans, timestamps, UUIDs, and more
  • Generics for reusable model templates (Paginated<T>, Result<T, E>)
  • Inheritance to compose models from shared base structures
  • Property mapping with automatic name conversion between camelCase, snake_case, and other strategies
  • Type aliases (public and private) for domain-specific types and string literal unions
  • Annotations for language-specific overrides and custom metadata
  • Multiple generators producing TypeScript interfaces, PHP mapper classes, and PHP SAMG maps from a single schema

Pipeline

The compilation pipeline transforms .scsc source through several stages:

Source  ──▶  Lexer  ──▶  Parser  ──▶  AST  ──▶  Evaluator  ──▶  Definition  ──▶  Generator
 .scsc       tokens      nodes       tree       resolved         output files
  1. The Lexer tokenizes source text into a stream of typed tokens
  2. The Parser builds an abstract syntax tree (AST) from the token stream
  3. The Evaluator walks the AST, resolves types, imports, and aliases, and produces a Definition
  4. Generators consume the Definition to emit language-specific code

You don’t need to understand the pipeline internals to use SchemaScript – this is just to give you a mental model of how .scsc files become generated code.

What’s Next

  • Getting Started walks you through installation and your first schema
  • The Language section is a complete reference for the .scsc syntax
  • The Toolchain section covers the CLI, build system, and generators

Getting Started

This guide walks you through installing SchemaScript, writing your first schema, and generating code from it.

Requirements

  • PHP >= 8.3
  • Composer

Installation

Install SchemaScript via Composer:

composer require clancats/schemascript

This adds the scsc CLI tool at vendor/bin/scsc.

Your First Schema

Create a file called SCHEMA.scsc in your project root:

import scsc/base

[generate] = {
  [ts.types] = {
    output = 'generated/ts/'
  }
}

User {
  id: int
  name: string
  email: string?
}

Post {
  id: int
  title: string
  body: string
  author: User
  tags: string[]
}

This schema defines two models (User and Post) and configures a single generator that will produce TypeScript type definitions.

Building

Run the build command from the directory containing your SCHEMA.scsc:

vendor/bin/scsc build

This generates the following files:

generated/ts/User.ts:

export interface User {
  id: number;
  name: string;
  email: string | null;
}

generated/ts/Post.ts:

import type { User } from './User';

export interface Post {
  id: number;
  title: string;
  body: string;
  author: User;
  tags: string[];
}

Notice how SchemaScript automatically:

  • Mapped int to number and string? to string | null
  • Generated an import statement for the User reference in Post
  • Produced a clean string[] array type for tags

Adding PHP Mappers

Extend your schema to also generate PHP mapper classes. Update the [generate] block:

import scsc/base

[map] = {
  api = {
    strategy = MappingStrategy::snakeCase
  }
}

[generate] = {
  [ts.types] = {
    output = 'generated/ts/'
    map = api
  }
  [php.mappers] = {
    output = 'generated/php/'
    namespace = 'App\\Mappers\\'
    map_from = self
    map_to = api
  }
}

User {
  id: int
  name: string
  email: string?
}

Post {
  id: int
  title: string
  body: string
  author: User
  tags: string[]
  createdAt: int
}

Run vendor/bin/scsc build again. Now you get TypeScript types (with snake_case property names from the api mapping) and PHP mappers that convert between camelCase local keys and snake_case API keys:

generated/php/PostMapper.php (excerpt):

class PostMapper
{
    public static function fromArray(array $data): array
    {
        $result = [];
        $result['id'] = (int) $data['id'];
        $result['title'] = (string) $data['title'];
        $result['body'] = (string) $data['body'];
        $result['author'] = UserMapper::fromArray($data['author']);
        $result['tags'] = array_map(fn($v) => (string) $v, $data['tags']);
        $result['createdAt'] = (int) $data['created_at'];
        return $result;
    }
}

The mapper automatically:

  • Casts each property to its PHP type
  • Delegates nested model references to their own mapper
  • Converts created_at (API/snake_case) to createdAt (local/camelCase)

Inspecting Your Schema

You can inspect the parsed schema without generating code:

# Print the evaluated Definition as JSON
vendor/bin/scsc SCHEMA.scsc

# Print the raw AST
vendor/bin/scsc ast SCHEMA.scsc

Quick Reference

CommandDescription
scsc buildBuild all generators from SCHEMA.scsc
scsc gen <name> <file>Run a specific generator
scsc <file>Parse and print as JSON
scsc ast <file>Print the AST
scsc --list-genList available generators

Next Steps

Syntax Overview

SchemaScript files use the .scsc extension. The language is block-based and whitespace-insensitive (outside of strings). A schema file contains a mix of top-level directives and model definitions.

Structure of a .scsc File

A typical schema file follows this structure:

// 1. Imports
import scsc/base

// 2. Global metadata
[version] = 1

// 3. Mapping configuration
[map] = {
  api = {
    strategy = MappingStrategy::snakeCase
  }
}

// 4. Generator configuration
[generate] = {
  [ts.types] = {
    output = 'output/ts/'
  }
}

// 5. Constants
const pk_t = uint64

// 6. Namespaces
ns Visibility {
  const public
  const private
}

// 7. Type aliases
[type] = {
  pub MessageType = 'text'|'image'|'video'
  position = { x: int, y: int }
}

// 8. Model definitions
User {
  id: pk_t
  name: string
  email: string?
  visibility: Visibility::public
}

// 9. Models with generics and inheritance
private SingleResponse<T> {
  error?: string
  data: T
}

UserResponse: SingleResponse<User> {}

Message {
  id: pk_t
  type: MessageType
  text: string?
  author: User?
  tags: string[]
  metadata: map<string, string>

  @map.api(modified_at)
  updatedAt: int
}

Top-Level Constructs

ConstructSyntaxPurpose
Importimport path/to/fileInclude another .scsc file
Comment// textLine comment
Metadata[key] = valueSchema-level configuration
Namespacens Name { ... }Group constants
Constantconst name = valueDefine a constant
Type block[type] = { ... }Define type aliases
ModelName { ... }Define a data model

Conventions

  • File extension: .scsc
  • Entry point: SCHEMA.scsc in the project root (used by scsc build)
  • Standard library: import scsc/base for built-in types
  • No semicolons: Statements are newline-separated
  • No commas: Properties and metadata entries are newline-separated (commas are not used)

Imports & Comments

Imports

Use import to include other .scsc files. Path segments are separated by /:

import scsc/base
import common
import models/user

The standard library provides scsc/base, which declares all built-in types (see Standard Library). Most schemas begin with import scsc/base.

Import paths are resolved relative to registered namespace directories. The SchemaScript toolchain resolves imports by scanning registered directories for .scsc files and mapping their file paths to import names. For example, a file at schemas/api/user.scsc registered under the prefix api would be imported as import api/user.

Circular imports are detected and produce an error.

Comments

Line comments use //:

// This is a comment
User {
  id: int
}

Multi-Line Comments

Multiple consecutive // lines form a multi-line comment:

// The rotation angle in radians.
// Formula to convert from degrees:
// degrees * (pi / 180)
rotation: float

Comments on Properties

Comments placed directly above a property are attached to that property and preserved through code generation. In TypeScript, they become JSDoc comments. In PHP, they become // comments.

User {
  // The user's unique identifier
  id: int

  // The user's display name
  name: string
}

Generated TypeScript (with include_comments = true):

export interface User {
  /** The user's unique identifier */
  id: number;
  /** The user's display name */
  name: string;
}

Multi-line comments above a property are preserved as multi-line JSDoc:

Box {
  // the rotation in radians
  // formula to convert from degrees to radians: degrees * (pi / 180)
  rotation: float
}
export interface Box {
  /**
   * the rotation in radians
   * formula to convert from degrees to radians: degrees * (pi / 180)
   */
  rotation: number;
}

Metadata

Top-level metadata uses bracket syntax to store schema-level configuration:

[version] = 1
[name] = 'MySchema'
[enabled] = true

Value Types

Metadata values can be:

TypeExample
String'hello' or "hello"
Number42, 3.14
Booleantrue, false
IdentifiersomeIdentifier
Namespace referenceMappingStrategy::snakeCase
Object{ key = value }
List{'red', 'green', 'blue'}

Metadata Objects vs Key-Value Objects

There are two different object syntaxes that look similar but produce different data structures.

Metadata Objects

Metadata objects use bracket keys ([key] = value). Each entry is an ordered tuple of key, value, and attributes (annotations). They support:

  • Annotations on entries
  • Dotted key names
  • Duplicate keys (preserved as separate entries)
[generate] = {
  [php.mappers] = {
    output = 'output/php/Mappers/'
    namespace = 'App\\Mappers\\'
  }
  [ts.types] = {
    output = 'output/ts/types/'
  }
}

Key-Value Objects

Key-value objects use plain identifiers (key = value). These are simple assignments with no annotation support. Duplicate keys override – only the last value is kept:

[config] = {
  name = 'example'
  nested = {
    deeper = {
      value = 42
    }
  }
}

Mixing Both Forms

Both forms can appear in the same object:

[generate] = {
  [php.mappers] = {
    output = 'output/php/Mappers/'
    namespace = 'App\\Mappers\\'
  }
  debug = false
}

Metadata Lists

Lists use {value, value} syntax:

[colors] = {'red', 'green', 'blue'}

Metadata with Annotations

Entries within metadata objects can have annotations:

[upgrades] = {
  @source('Asgard')
  [asgard_core] = 'Complete Asgard knowledge base'

  @source('Ancient')
  [zpm] = 'Zero Point Module power augmentation'
}

Common Metadata Blocks

[version]

Schema format version:

[version] = 1

[map]

Property mapping configuration (see Property Mapping):

[map] = {
  api = {
    strategy = MappingStrategy::snakeCase
  }
}

[generate]

Generator configuration (see Build System):

[generate] = {
  [ts.types] = {
    output = 'output/ts/'
  }
}

[type]

Type alias definitions (see Type Aliases):

[type] = {
  pub MessageType = 'text'|'image'|'video'
}

Model-Level Metadata

Metadata can also appear inside model definitions:

User {
  [version] = 2
  [map:local] = 'camelCase'

  id: int
  name: string
}

Types

SchemaScript has a rich type system with simple types, arrays, nullable types, unions, inline objects, string literals, and generics.

Simple Types

A simple type is an identifier referencing a built-in type, a type alias, or a model name:

id: int
name: string
author: User
status: MessageType

String Literal Types

Quoted strings can be used as types, typically combined with unions to constrain a property to specific values:

type: 'text'|'image'|'video'
status: 'active'|'inactive'|'pending'

Arrays

Append [] to a type to make it an array:

colors: int[]
tags: string[]
messages: Message[]

Nullable Types

Append ? to a type to make the value nullable:

name: string?
avatar_url: string?
items: int[]?

string? means the value can be a string or null. int[]? means the value can be an array of ints or null.

Union Types

Combine types with |:

data: Image|User
result: string|int
type: 'text'|'image'|'video'

Inline Objects

Curly braces define anonymous object types with their own properties:

avatar_image: {
  url: string
  width: int
  height: int
}

last_messages: {
  cached: bool
  data: Message[]
}

Inline objects are expanded inline in generated code – they don’t produce standalone types.

Generic Types

Angle brackets provide type arguments to generic models:

pages: Paginated<User>
config: map<string, string>
nested: map<string, map<string, int>>

See Generics for details on defining and using generic models.

Type Precedence

Type modifiers bind in this order (tightest first):

  1. [] (array)
  2. ? (nullable)
  3. | (union)

This means:

ExpressionMeaning
int[]Array of int
int[]?Nullable array of int
string|intString or int
string|int?String, or nullable int

Parentheses

Parentheses override the default precedence:

// Array of nullable int
items: (int?)[]

// Nullable union
value: (string|int)?

// Array of arrays of nullable int
matrix: ((int?)[])[]

Without parentheses, int?[] would mean “nullable array of int”. With parentheses, (int?)[] means “array of nullable int”.

Type Aliases

Type blocks define type aliases and declare custom types using [type] = { ... }:

[type] = {
  int64
  timestamp = uint64
  uuid = string
}

Bare Type Declarations

A bare identifier (no =) registers a type name without aliasing it to another type:

[type] = {
  int64
  uint64
}

This is primarily used by the standard library to register built-in type names.

Type Aliases

Use = to alias one type to another. Aliases can reference any type expression – simple types, unions, arrays, nullable types, or inline objects:

[type] = {
  pk_t = uint64
  messageType = 'text'|'image'|'video'
  tags = string[]
  optionalName = string?
  position = {
    x: int
    y: int
  }
}

By default, type aliases are private – generators expand them inline wherever they are used. A property typed as pk_t will appear as bigint in TypeScript and string in PHP (based on the resolved uint64 type), not as a named pk_t type.

Public Type Aliases (pub)

Prefix a type alias with pub to make it public. Public aliases are emitted as standalone, exported types instead of being expanded inline:

[type] = {
  pub MessageType = 'text'|'image'|'video'
  pub Position = {
    x: int
    y: int
  }
  internal_status = 'active'|'inactive'   // private, expanded inline
}

TypeScript Output

Public type aliases are collected into a shared _types.ts file. Object-shaped types become export interface, others become export type:

// _types.ts
export type MessageType = 'text' | 'image' | 'video';

export interface Position {
  x: number;
  y: number;
}

Model files that reference public types automatically import them:

// Message.ts
import type { MessageType } from './_types';

export interface Message {
  type: MessageType;
}

PHP Output

Public object-shaped type aliases generate their own mapper class (e.g., PositionMapper.php). Models referencing them delegate to that mapper.

Type Alias Annotations

Type aliases can have annotations, commonly used for language-specific type overrides:

[type] = {
  @lang.php('int')
  @lang.ts('bigint')
  int64

  @lang.php('string')
  @lang.ts('string')
  uuid = string
}

The @lang.php and @lang.ts annotations tell generators what native type to use when emitting code for this type. See Annotations for the full annotation reference.

Scoped Type Blocks

Type blocks can appear inside models. Types defined in a model scope are available to that model and its children, but not to sibling or parent models:

Api {
  [type] = {
    apiId = string
    apiTimestamp = int64
  }

  Request {
    id: apiId
    received_at: apiTimestamp
  }

  Response {
    timestamp: apiTimestamp
  }
}

// apiId and apiTimestamp are NOT available here
User {
  id: int
}

Models

Models are the primary building blocks of a SchemaScript schema. They define named data structures with typed properties.

Basic Syntax

A model is a named block containing property definitions:

User {
  id: int
  name: string
  email: string?
}

Each model produces one output file per generator (e.g., User.ts for TypeScript, UserMapper.php for PHP mappers).

Model-Level Metadata

Models can contain metadata directives that configure behavior specific to that model:

User {
  [version] = 2
  [map:local] = 'camelCase'

  id: int
  name: string
}

Child Models

Models can be nested inside other models:

User {
  id: int
  name: string

  Profile {
    bio: string
    avatar: string?
  }

  Settings {
    theme: string
    notifications: bool
  }
}

Child models are full models in their own right – they generate their own output files and can be referenced as types by other models.

Private Models

The private keyword prevents a model from being emitted by generators. Private models are useful as base types for inheritance or as generic templates:

private PaginationMeta {
  total_count: int
  filtered_count: int
}

private SingleResponse<T> {
  error?: string
  data: T
}

// Public models that inherit from private ones
UserResponse: SingleResponse<User> {}

UserCollectionResponse: PaginationMeta {
  error?: string
  data: User[]
}

In this example:

  • PaginationMeta and SingleResponse are not emitted as standalone types
  • UserResponse inherits SingleResponse’s properties (with T resolved to User) and is emitted
  • UserCollectionResponse inherits PaginationMeta’s properties and is emitted

Generated TypeScript for UserCollectionResponse:

import type { User } from './User';

export interface UserCollectionResponse {
  total_count: number;
  filtered_count: number;
  error?: string;
  data: User[];
}

The inherited properties from PaginationMeta are flattened directly into the output.

See Also

Properties

Properties define the fields of a model using name: type syntax.

Basic Properties

User {
  id: int
  name: string
  email: string
  age: uint8
  balance: float
  active: bool
}

Nullable Types

Append ? to the type to indicate the value can be null:

User {
  id: int
  name: string
  avatar_url: string?    // can be string or null
  bio: string?           // can be string or null
}

In generated TypeScript, string? becomes string | null. In PHP mappers, nullable properties include a null check before type casting.

Optional Keys

Append ? to the key name (before the colon) to make the key itself optional. An optional key may or may not be present in the data:

Message {
  id: int
  text: string
  unseen?: bool          // key may be absent entirely
}

This is distinct from nullable types:

SyntaxMeaning
name: string?Key is always present, value can be null
name?: stringKey may be absent, value is a string when present
name?: string?Key may be absent, value can be null when present

In generated TypeScript, optional keys use the ? property syntax:

export interface Message {
  id: number;
  text: string;
  unseen?: boolean;
}

In PHP mappers, optional keys are guarded with array_key_exists:

if (array_key_exists('unseen', $data)) {
    $result['unseen'] = (bool) $data['unseen'];
}

Property Annotations

Annotations placed directly above a property add metadata to it:

User {
  @local(avatarImageId)
  avatar_image_id: int?

  @enum("text", "image", "video")
  type: string

  @map.api(modified_at)
  updatedAt: int
}

See Annotations for the full annotation reference.

Property Comments

Comments placed directly above a property are attached to it and preserved through code generation:

User {
  // The user's unique identifier
  id: int

  // The user's display name, shown in the UI
  name: string
}

See Imports & Comments for details on comment formatting in generated code.

Generics

SchemaScript supports generic type parameters on models and generic type instantiation in property types.

Declaring Generic Models

Models can declare type parameters in angle brackets after the model name:

Paginated<T> {
  items: T[]
  total: int
}

Result<T, E> {
  data: T?
  error: E?
}

Type parameters (T, E, etc.) can be used anywhere a type is expected inside the model body – in arrays, nullable types, unions, and inline objects.

Generic Type Instantiation

Use a generic model by providing concrete type arguments in angle brackets:

UserList {
  pages: Paginated<User>
  outcome: Result<User, string>
}

The number of type arguments must match the number of type parameters declared on the model. Type parameter names must be unique within a model declaration.

Combining with Other Type Modifiers

Generic types follow the same precedence rules as other types and can be combined with arrays, nullable, and unions:

Config {
  items: map<string, int>[]
  cache: map<string, User>?
  nested: map<string, map<string, int>>
}

Child Model Scope

Child models nested inside a generic model can reference the parent’s type parameters:

Container<T> {
  value: T

  Metadata {
    item: T
    label: string
  }
}

Generic Models and Inheritance

Generic models can be used as parent types. The type parameters are resolved at the inheritance site:

private SingleResponse<T> {
  error?: string
  data: T
}

private CollectionResponse<T> {
  error?: string
  data: T[]
}

UserResponse: SingleResponse<User> {}
UserCollectionResponse: CollectionResponse<User> {}

UserResponse inherits SingleResponse’s properties with T resolved to User:

// UserResponse.ts
import type { User } from './User';

export interface UserResponse {
  error?: string;
  data: User;
}

UserCollectionResponse similarly resolves T to User:

// UserCollectionResponse.ts
import type { User } from './User';

export interface UserCollectionResponse {
  error?: string;
  data: User[];
}

Standard Library Generic: map

The standard library provides map<K, V>, a key-value mapping type. It generates language-appropriate output:

  • TypeScript: Record<K, V>
  • PHP: array with value casting via array_map()
Settings {
  config: map<string, string>
  scores: map<string, int>
}

Generated TypeScript:

export interface Settings {
  config: Record<string, string>;
  scores: Record<string, number>;
}

Generated PHP mapper (excerpt):

$result['config'] = array_map(fn($v) => (string) $v, $data['config']);
$result['scores'] = array_map(fn($v) => (int) $v, $data['scores']);

Custom Generic Models with Language Annotations

To control how a custom generic model is emitted by generators, use @lang.php and @lang.ts annotations. Generic models with these annotations are not emitted as standalone types – instead, generators use the annotation value at each usage site:

@lang.php('array')
@lang.ts('Record')
map<K, V> {}

Generic models without language annotations are emitted as proper generic types in languages that support them. For example, TypeScript emits export interface Paginated<T> { ... }. PHP generators skip generic template models entirely and only handle concrete instantiations through inheritance.

How Generators Handle Generics

ScenarioTypeScriptPHP
Generic template (Paginated<T>)Emits interface Paginated<T>Skips (not emitted)
Generic with @lang.ts/@lang.phpUses annotation value (e.g., Record<K, V>)Uses annotation value
Concrete instantiation via inheritanceResolves type parameters, emits flat interfaceResolves type parameters, emits mapper
private generic templateNot emittedNot emitted

Inheritance

Models can inherit properties from other models using colon syntax.

Basic Inheritance

Use : after the model name to inherit from a parent model:

private PaginationMeta {
  total_count: int
  filtered_count: int
}

UserCollectionResponse: PaginationMeta {
  error?: string
  data: User[]
}

The child model (UserCollectionResponse) inherits all properties from the parent (PaginationMeta). Inherited properties appear before the child’s own properties in the generated output:

export interface UserCollectionResponse {
  total_count: number;      // inherited from PaginationMeta
  filtered_count: number;   // inherited from PaginationMeta
  error?: string;           // own property
  data: User[];             // own property
}

Properties are flattened – there is no runtime prototype chain or base class in the generated code.

Inheritance with Generics

Parent models can be generic. Type parameters are resolved at the inheritance site:

private SingleResponse<T> {
  error?: string
  data: T
}

private CollectionResponse<T>: PaginationMeta {
  error?: string
  data: T[]
}

UserResponse: SingleResponse<User> {}
UserCollectionResponse: CollectionResponse<User> {}

UserResponse resolves T to User, producing:

export interface UserResponse {
  error?: string;
  data: User;
}

CollectionResponse<T> itself inherits from PaginationMeta, so UserCollectionResponse gets properties from both:

export interface UserCollectionResponse {
  total_count: number;       // from PaginationMeta (via CollectionResponse)
  filtered_count: number;    // from PaginationMeta (via CollectionResponse)
  error?: string;            // from CollectionResponse
  data: User[];              // from CollectionResponse, T resolved to User
}

Private Parents

Parent models are often marked private since they serve as templates and shouldn’t produce their own output files. Only the concrete child models are emitted by generators:

private BaseEntity {
  id: uint64
  created_at: timestamp
  updated_at: timestamp
}

User: BaseEntity {
  name: string
  email: string
}

Post: BaseEntity {
  title: string
  body: string
}

Both User and Post will include id, created_at, and updated_at in their generated output. BaseEntity itself is not emitted.

Empty Child Models

A child model with no additional properties is valid – it simply takes all properties from its parent:

UserResponse: SingleResponse<User> {}

This is a common pattern for creating concrete types from generic templates.

Constants & Namespaces

Constants are enumeration-style symbols. They can be declared at the root scope or grouped inside namespaces.

Root-Level Constants

const pk_t = uint64
const defaultMapping = MappingType::camelCase

Root-level constants with a type alias value (like pk_t = uint64) can be used as types in property definitions:

const pk_t = uint64

User {
  id: pk_t    // resolves to uint64
}

Namespaces

Constants grouped inside ns blocks form a namespace:

ns MappingStrategy {
  const camelCase
  const pascalCase
  const snakeCase
  const screamingSnakeCase
  const kebabCase
}

ns Visibility {
  const public
  const private
  const internal
}

Namespaces can be nested:

ns SCSC {
  ns Lang {
    ns PHP {
      ns Type {
        const int = 'int'
        const string = 'string'
        const bool = 'bool'
      }
    }
  }
}

Referencing Constants

Constants are referenced using :: syntax:

[map] = {
  api = {
    strategy = MappingStrategy::snakeCase
  }
}

Nested namespaces use chained :::

@lang.php(SCSC::Lang::PHP::Type::int)
int64

Constants Without Values

Constants declared without an assigned value resolve to their qualified name as a string. For example, MappingStrategy::camelCase resolves to the string "MappingStrategy::camelCase":

ns Visibility {
  const public      // resolves to "Visibility::public"
  const private     // resolves to "Visibility::private"
}

This makes them useful as enum-like symbols in metadata and configuration.

Standard Library Namespaces

The standard library (scsc/base) provides several predefined namespaces:

NamespaceConstants
MappingStrategycamelCase, pascalCase, snakeCase, screamingSnakeCase, kebabCase
SCSC::Lang::PHP::Typeint, string, bool, float, array, object, null, mixed
SCSC::Lang::TS::Typenumber, string, boolean, any, null, undefined, object, bigint, Uint8Array, unknown

These are used internally by the standard library’s type annotations and by generators, but you can also reference them in your own schemas.

Annotations

Annotations add metadata to properties, type aliases, and metadata entries using @name or @name(args) syntax.

Syntax

@local(avatarImageId)
avatar_image_id: int?

@enum("text", "image", "video")
type: string

Dotted Names

Annotation names can be dotted for namespacing:

@lang.php('int')
@lang.ts('number')
@map.api(field_name)

Arguments

Arguments are comma-separated and can be strings, numbers, or identifiers:

@name('string argument')
@name("double quoted string")
@name(42)
@name(SomeIdentifier)
@name('first', 'second', 'third')

Where Annotations Can Appear

On Properties

User {
  @local(avatarImageId)
  avatar_image_id: int?

  @map.api(modified_at)
  updatedAt: int
}

On Type Aliases

[type] = {
  @lang.php('int')
  @lang.ts('bigint')
  int64
}

On Metadata Entries

[upgrades] = {
  @source('Asgard')
  [asgard_core] = 'Complete Asgard knowledge base'
}

Known Annotations Reference

AnnotationArgumentsPurpose
@local(name)Identifier or stringOverride the local property name used in PHP mappers
@enum(values...)StringsConstrain a property to specific string values
@lang.php(type)String or namespace refOverride the PHP type for a type alias
@lang.ts(type)String or namespace refOverride the TypeScript type for a type alias
@map.<name>(key)Identifier or stringOverride the property key for a specific mapping context

@local(name)

Explicitly sets the local property name, overriding any automatic naming:

@local(avatarImageId)
avatar_image_id: int?

In PHP mappers, the local key will be avatarImageId regardless of the mapping strategy.

@enum(values...)

Documents the allowed values for a string property:

@enum("text", "image", "video")
type: string

@lang.php(type) and @lang.ts(type)

Override the target language type for a type alias. These are primarily used in the standard library to map SchemaScript types to native types:

[type] = {
  @lang.php('int')
  @lang.ts('number')
  int32

  @lang.php('string')
  @lang.ts('bigint')
  uint64
}

@map.<name>(key)

Override the property key for a specific mapping context:

Message {
  @map.api(modified_at)
  updatedAt: int
}

When the api mapping is applied, this property will use the key modified_at instead of the automatically converted name. See Property Mapping for details.

Standard Library

The standard library (scsc/base) provides built-in types, mapping strategies, and language-specific type constants. Import it with:

import scsc/base

Built-in Types

Integer Types

SchemaScriptPHP TypeTypeScript Type
intintnumber
int8intnumber
int16intnumber
int32intnumber
int64intnumber

Unsigned Integer Types

SchemaScriptPHP TypeTypeScript Type
uintintnumber
uint8intnumber
uint16intnumber
uint32intnumber
uint64stringbigint

Note: uint64 maps to string in PHP and bigint in TypeScript because 64-bit unsigned integers exceed the safe integer range of PHP’s int and JavaScript’s number.

Floating Point Types

SchemaScriptPHP TypeTypeScript Type
floatfloatnumber
float32floatnumber
float64floatnumber
doublefloatnumber

Text & Binary

SchemaScriptPHP TypeTypeScript Type
stringstringstring
bytesstringUint8Array

Boolean

SchemaScriptPHP TypeTypeScript Type
boolboolboolean

Identifiers

SchemaScriptPHP TypeTypeScript Type
uuidstringstring

Temporal Types

SchemaScriptPHP TypeTypeScript Type
timestampstringstring
datetimestringstring
datestringstring
timestringstring

Dynamic Types

SchemaScriptPHP TypeTypeScript Type
anymixedunknown
mixedmixedunknown

Collections

SchemaScriptPHP TypeTypeScript Type
map<K, V>arrayRecord<K, V>

map<K, V> is a generic key-value mapping type. See Generics for details.

Mapping Strategies

The MappingStrategy namespace provides constants for property name conversion:

ConstantExample
MappingStrategy::camelCasemyProperty
MappingStrategy::pascalCaseMyProperty
MappingStrategy::snakeCasemy_property
MappingStrategy::screamingSnakeCaseMY_PROPERTY
MappingStrategy::kebabCasemy-property

Usage:

[map] = {
  api = {
    strategy = MappingStrategy::snakeCase
  }
}

See Property Mapping for details.

Language Type Constants

The standard library also provides namespace constants for native language types. These are used internally by type alias annotations:

PHP types (SCSC::Lang::PHP::Type::*): int, string, bool, float, array, object, null, mixed

TypeScript types (SCSC::Lang::TS::Type::*): number, string, boolean, any, null, undefined, object, bigint, Uint8Array, unknown

You can reference these in your own @lang.php and @lang.ts annotations:

[type] = {
  @lang.php(SCSC::Lang::PHP::Type::int)
  @lang.ts(SCSC::Lang::TS::Type::number)
  myCustomInt
}

CLI Reference

The SchemaScript CLI (scsc) is the main entry point for working with .scsc files. It can parse, evaluate, build, generate, and import schemas.

Usage

vendor/bin/scsc <command> [arguments] [options]

Commands

scsc build

Build all generators configured in the SCHEMA.scsc file in the current working directory.

vendor/bin/scsc build

This is the default command when no arguments are provided. It reads the [generate] metadata block and runs each configured generator, writing output files to the specified directories.

See Build System for details on the SCHEMA.scsc format.

scsc <file>

Parse and evaluate a .scsc file, printing the resulting Definition as JSON:

vendor/bin/scsc schema.scsc

This is useful for inspecting the fully resolved schema – all imports resolved, type aliases expanded, and inheritance flattened.

scsc ast <file>

Print the abstract syntax tree (AST) of a .scsc file as JSON:

vendor/bin/scsc ast schema.scsc

This shows the raw parse tree before evaluation. Useful for debugging parser behavior or understanding how the language is parsed.

scsc gen <generator> <file>

Run a specific generator on a schema file:

vendor/bin/scsc gen ts-types schema.scsc --output=generated/ts/
vendor/bin/scsc gen php-mappers schema.scsc --output=generated/php/
vendor/bin/scsc gen php.samg schema.scsc --output=generated/samg/

Options:

OptionDescription
--output=<dir>Output directory for generated files
--stdoutPrint generated code to stdout instead of writing files

Note: Generator names use hyphens on the CLI (ts-types, php-mappers) but dots in SCHEMA.scsc configuration (ts.types, php.mappers).

scsc import <importer> <file>

Import from an external format into SchemaScript:

vendor/bin/scsc import samg SAMG.md --output=schema.scsc
vendor/bin/scsc import samg SAMG.md --stdout

Options:

OptionDescription
--output=<file>Output file path for the generated .scsc
--stdoutPrint generated .scsc to stdout

See Importers for available importers.

scsc --list-gen

List all available generators:

vendor/bin/scsc --list-gen

scsc --list-import

List all available importers:

vendor/bin/scsc --list-import

Examples

# Full build from SCHEMA.scsc
vendor/bin/scsc build

# Inspect the evaluated schema
vendor/bin/scsc SCHEMA.scsc

# Generate only TypeScript types to stdout
vendor/bin/scsc gen ts-types SCHEMA.scsc --stdout

# Import a SAMG file and pipe directly to a generator
vendor/bin/scsc import samg SAMG.md --stdout | vendor/bin/scsc gen php-mappers /dev/stdin --stdout

Build System

The scsc build command orchestrates code generation from a SCHEMA.scsc file. It compiles the schema, validates it, and runs all configured generators.

The SCHEMA.scsc File

By convention, SchemaScript projects use a file called SCHEMA.scsc in the project root. This file combines the schema definitions with generator configuration.

Structure

A typical SCHEMA.scsc follows this structure:

import scsc/base

[version] = 1

[map] = {
  api = {
    strategy = MappingStrategy::snakeCase
  }
}

[generate] = {
  [ts.types] = {
    output = 'output/ts/types/'
    include_comments = true
    map = api
  }
  [php.mappers] = {
    output = 'output/php/Mappers/'
    namespace = 'App\\Mappers\\'
    map_from = self
    map_to = api
  }
  [php.samg] = {
    output = 'output/php/SAMG/'
    namespace = 'App\\SAMG\\'
    map_from = self
    map_to = api
  }
}

// Type aliases, constants, and model definitions follow...

The [generate] Block

The [generate] metadata block configures which generators to run and their options. Each entry is a metadata object keyed by the generator name:

[generate] = {
  [<generator-name>] = {
    output = '<output-directory>'
    // ... generator-specific options
  }
}

Required Options

OptionDescription
outputDirectory where generated files are written (relative to the schema file)

Common Options

OptionDescriptionUsed by
namespacePHP namespace prefix for generated classesphp.mappers, php.samg
map_fromSource mapping context (default: self)php.mappers, php.samg
map_toTarget mapping contextphp.mappers, php.samg
mapWhich mapping context to apply for property keysts.types
include_commentsInclude doc comments in outputts.types, php.mappers

See individual generator pages for the full list of options:

Available Generators

Name (SCHEMA.scsc)Name (CLI)Description
ts.typests-typesTypeScript interface definitions
php.mappersphp-mappersPHP mapper classes with fromArray/toArray
php.samgphp.samgPHP SAMG v2 mapper classes with 10 methods

Build Process

When you run scsc build, the following happens:

  1. The SCHEMA.scsc file is compiled (lexed, parsed, evaluated) with import resolution
  2. The resulting Definition is validated
  3. Each entry in the [generate] block is processed:
    • The generator is looked up by name
    • The generator receives the Definition and its configured options
    • Generated files are written to the specified output directory
  4. A summary of written files is printed

Full Example

Here is the integration test SCHEMA.scsc as a complete reference:

import scsc/base

[version] = 1

[map] = {
  api = {
    strategy = MappingStrategy::snakeCase
  }
  db = {
    strategy = MappingStrategy::snakeCase
  }
}

[generate] = {
  [php.mappers] = {
    output = 'output/php/Mappers/'
    namespace = 'IntegrationEx\\Mappers\\'
    map_from = self
    map_to = api
  }
  [php.samg] = {
    output = 'output/php/SAMG/'
    namespace = 'IntegrationEx\\SAMG\\'
    map_from = self
    map_to = api
  }
  [ts.types] = {
    output = 'output/ts/types/'
    include_comments = true
    map = api
  }
}

const pk_t = uint64

private PaginationMeta {
  total_count: int
  filtered_count: int
}

private SingleResponse<T> {
  error?: string
  data: T
}

private CollectionResponse<T>: PaginationMeta {
  error?: string
  data: T[]
}

User {
  id: pk_t
  name: string
}

UserResponse: SingleResponse<User> {}
UserCollectionResponse: CollectionResponse<User> {}

[type] = {
  position = { x: int, y: int }
  size = { width: int, height: int }
}

Box {
  // the position of the box
  pos: position
  // the size of the box
  size: size
  // the rotation in radians
  // formula to convert from degrees to radians: degrees * (pi / 180)
  rotation: float
}

[type] = {
  pub MessageType = 'text'|'image'|'video'
}

Message {
  id: pk_t
  type: MessageType
  text: string?
  actor: User?
  unseen?: bool
  payload: map<string, string>
  createdAt: int
  @map.api(modified_at)
  updatedAt: int
}

Property Mapping

Property mapping transforms property names between different naming conventions. This is how a camelCase property in your schema becomes a snake_case key in a REST API.

Defining Mapping Strategies

The [map] metadata block defines named mapping contexts:

[map] = {
  api = {
    strategy = MappingStrategy::snakeCase
  }
  db = {
    strategy = MappingStrategy::snakeCase
  }
}

Each named context (like api or db) specifies a naming strategy for property keys.

Available Strategies

StrategyExample InputExample Output
MappingStrategy::camelCasetotal_counttotalCount
MappingStrategy::pascalCasetotal_countTotalCount
MappingStrategy::snakeCasetotalCounttotal_count
MappingStrategy::screamingSnakeCasetotalCountTOTAL_COUNT
MappingStrategy::kebabCasetotalCounttotal-count

Using Mappings in Generators

Generators reference mapping contexts through their configuration options:

TypeScript Types

The map option specifies which mapping context to use for property names in the output:

[generate] = {
  [ts.types] = {
    output = 'output/ts/'
    map = api
  }
}

With map = api and MappingStrategy::snakeCase, a property createdAt becomes created_at in the TypeScript interface.

PHP Mappers & SAMG

PHP generators use map_from and map_to to define the source and target contexts:

[generate] = {
  [php.mappers] = {
    output = 'output/php/'
    namespace = 'App\\Mappers\\'
    map_from = self
    map_to = api
  }
}
  • map_from = self means the local/input keys use the original property names from the schema
  • map_to = api means the target/output keys use the api mapping strategy

This generates mappers that convert between camelCase local keys and snake_case API keys:

// fromArray: api -> local
$result['createdAt'] = (int) $data['created_at'];

// toArray: local -> api
$result['created_at'] = (int) $data['createdAt'];

Per-Property Overrides

@map.<name>(key)

Override the mapped key for a specific context:

Message {
  createdAt: int

  @map.api(modified_at)
  updatedAt: int
}

Without the annotation, updatedAt would be automatically converted to updated_at by the snakeCase strategy. The @map.api(modified_at) annotation overrides this, explicitly setting the API key to modified_at.

@local(name)

Override the local property name:

@local(avatarImageId)
avatar_image_id: int?

In PHP mappers, the local key will be avatarImageId regardless of the mapping strategy applied.

Walkthrough Example

Given this schema:

import scsc/base

[map] = {
  api = {
    strategy = MappingStrategy::snakeCase
  }
}

[generate] = {
  [ts.types] = {
    output = 'output/ts/'
    map = api
  }
  [php.mappers] = {
    output = 'output/php/'
    namespace = 'App\\Mappers\\'
    map_from = self
    map_to = api
  }
}

Message {
  id: uint64
  messageText: string
  createdAt: int
  @map.api(modified_at)
  updatedAt: int
}

TypeScript output (using api map = snake_case):

export interface Message {
  id: bigint;
  message_text: string;
  created_at: number;
  modified_at: number;    // from @map.api annotation
}

PHP mapper (converting between self and api):

public static function fromArray(array $data): array
{
    $result = [];
    $result['id'] = (string) $data['id'];
    $result['messageText'] = (string) $data['message_text'];
    $result['createdAt'] = (int) $data['created_at'];
    $result['updatedAt'] = (int) $data['modified_at'];
    return $result;
}

public static function toArray(array $data): array
{
    $result = [];
    $result['id'] = (string) $data['id'];
    $result['message_text'] = (string) $data['messageText'];
    $result['created_at'] = (int) $data['createdAt'];
    $result['modified_at'] = (int) $data['updatedAt'];
    return $result;
}

Notice how:

  • messageText (camelCase) maps to message_text (snake_case) automatically
  • updatedAt maps to modified_at (from the @map.api annotation, overriding the automatic updated_at)

Code Generators

Generators take the compiled Definition from a .scsc file and produce language-specific output files. SchemaScript ships with three built-in generators.

Available Generators

GeneratorOutputDescription
TypeScript Types.ts filesTypeScript interfaces and type aliases
PHP Mappers.php filesPHP classes with fromArray/toArray methods
PHP SAMG.php filesPHP classes with 10 bidirectional mapping methods

Common Behavior

All generators share these behaviors:

Private Models

Models marked private are not emitted as standalone output files. Their properties are available through inheritance – when a public model inherits from a private one, the inherited properties are flattened into the child’s output.

Generic Template Models

Generic models with unresolved type parameters (e.g., Paginated<T>) are skipped by generators. Only concrete instantiations (via inheritance like UserList: Paginated<User> {}) produce output.

The exception is TypeScript, which can emit generic interfaces directly (e.g., export interface Paginated<T> { ... }) for models without @lang.ts annotations.

Public Type Aliases

Type aliases marked with pub produce standalone output:

  • TypeScript: Collected into a shared _types.ts file
  • PHP: Object-shaped public aliases produce their own mapper class

Private type aliases (the default) are expanded inline wherever they are used.

Language Annotations

Generic models with @lang.ts or @lang.php annotations use the annotation value instead of emitting the model. For example, map<K, V> with @lang.ts('Record') emits Record<K, V> in TypeScript instead of generating a map interface.

Configuration

Generators are configured in the [generate] metadata block of a SCHEMA.scsc file:

[generate] = {
  [ts.types] = {
    output = 'output/ts/'
    map = api
    include_comments = true
  }
  [php.mappers] = {
    output = 'output/php/Mappers/'
    namespace = 'App\\Mappers\\'
    map_from = self
    map_to = api
  }
}

See Build System for details on the configuration format.

Running Generators

Generators run automatically with scsc build, or individually with scsc gen:

# Run all generators
vendor/bin/scsc build

# Run a specific generator
vendor/bin/scsc gen ts-types schema.scsc --output=output/ts/
vendor/bin/scsc gen php-mappers schema.scsc --output=output/php/

List available generators:

vendor/bin/scsc --list-gen

TypeScript Types Generator

The ts.types generator produces TypeScript interface definitions and type aliases from SchemaScript models.

Configuration

[generate] = {
  [ts.types] = {
    output = 'output/ts/types/'
    map = api
    include_comments = true
  }
}

Options

OptionRequiredDescription
outputYesOutput directory for generated .ts files
mapNoMapping context to apply for property names
include_commentsNoInclude JSDoc comments from schema comments

CLI name: ts-types

vendor/bin/scsc gen ts-types schema.scsc --output=output/ts/

Output Files

The generator produces:

  • One .ts file per model named after the model (e.g., User.ts, Message.ts)
  • _types.ts if there are public type aliases – contains all pub type aliases

Examples

Simple Model

User {
  id: uint64
  name: string
}

User.ts:

export interface User {
  id: bigint;
  name: string;
}

Model with References and Nullable Types

Message {
  id: uint64
  type: MessageType
  text: string?
  actor: User?
  unseen?: bool
  payload: map<string, string>
  created_at: int
  modified_at: int
}

Message.ts:

import type { MessageType } from './_types';
import type { User } from './User';

export interface Message {
  id: bigint;
  type: MessageType;
  text: string | null;
  actor: User | null;
  unseen?: boolean;
  payload: Record<string, string>;
  created_at: number;
  modified_at: number;
}

Public Type Aliases

[type] = {
  pub MessageType = 'text'|'image'|'video'
}

_types.ts:

export type MessageType = 'text' | 'image' | 'video';

Inline Objects with Comments

Box {
  // the position of the box
  pos: position
  // the size of the box
  size: size
  // the rotation in radians
  // formula to convert from degrees to radians: degrees * (pi / 180)
  rotation: float
}

Where position = { x: int, y: int } and size = { width: int, height: int } are private type aliases:

Box.ts:

export interface Box {
  /** the position of the box */
  pos: {
    x: number;
    y: number;
  };
  /** the size of the box */
  size: {
    width: number;
    height: number;
  };
  /**
   * the rotation in radians
   * formula to convert from degrees to radians: degrees * (pi / 180)
   */
  rotation: number;
}

Inheritance with Generics

private SingleResponse<T> {
  error?: string
  data: T
}

UserResponse: SingleResponse<User> {}

UserResponse.ts:

import type { User } from './User';

export interface UserResponse {
  error?: string;
  data: User;
}

Inherited Properties from Multiple Parents

private PaginationMeta {
  total_count: int
  filtered_count: int
}

private CollectionResponse<T>: PaginationMeta {
  error?: string
  data: T[]
}

UserCollectionResponse: CollectionResponse<User> {}

UserCollectionResponse.ts:

import type { User } from './User';

export interface UserCollectionResponse {
  total_count: number;
  filtered_count: number;
  error?: string;
  data: User[];
}

Type Mapping Reference

SchemaScriptTypeScript
int, int8int64number
uintuint32number
uint64bigint
float, doublenumber
stringstring
boolboolean
bytesUint8Array
uuid, timestamp, datetime, date, timestring
any, mixedunknown
T[]T[]
T?T | null
map<K, V>Record<K, V>
'a'|'b'|'c''a' | 'b' | 'c'
{ x: int }{ x: number }
Model referenceImport + type name

PHP Mappers Generator

The php.mappers generator produces PHP mapper classes with fromArray and toArray static methods for bidirectional data transformation.

Configuration

[generate] = {
  [php.mappers] = {
    output = 'output/php/Mappers/'
    namespace = 'App\\Mappers\\'
    map_from = self
    map_to = api
  }
}

Options

OptionRequiredDescription
outputYesOutput directory for generated .php files
namespaceNoPHP namespace prefix for generated classes
map_fromNoSource mapping context (default: self)
map_toNoTarget mapping context
include_commentsNoInclude comments in generated code

CLI name: php-mappers

vendor/bin/scsc gen php-mappers schema.scsc --output=output/php/

Output Files

One <Model>Mapper.php file per public model. Each class has two static methods:

  • fromArray(array $data): array – Converts from the map_to context to the map_from context (e.g., API data to local format)
  • toArray(array $data): array – Converts from the map_from context to the map_to context (e.g., local format to API data)

Features

Type Casting

Each property is automatically cast to its PHP type:

$result['id'] = (string) $data['id'];       // uint64 -> string
$result['name'] = (string) $data['name'];    // string -> string
$result['age'] = (int) $data['age'];         // int -> int
$result['score'] = (float) $data['score'];   // float -> float
$result['active'] = (bool) $data['active'];  // bool -> bool

Nullable Properties

Nullable types include a null check:

$result['text'] = ($data['text'] !== null ? (string) $data['text'] : null);
$result['actor'] = ($data['actor'] !== null ? UserMapper::fromArray($data['actor']) : null);

Optional Keys

Optional keys (name?:) are guarded with array_key_exists:

if (array_key_exists('unseen', $data)) {
    $result['unseen'] = (bool) $data['unseen'];
}

Nested Model References

Properties referencing other models delegate to their mapper:

$result['actor'] = UserMapper::fromArray($data['actor']);
$result['actor'] = ($data['actor'] !== null ? UserMapper::fromArray($data['actor']) : null);

Map Types

map<K, V> properties use array_map() for value casting:

$result['payload'] = array_map(fn($v) => (string) $v, $data['payload']);
$result['scores'] = array_map(fn($v) => (int) $v, $data['scores']);

Property Name Mapping

When map_from and map_to differ, property keys are converted:

// fromArray: api keys -> local keys
$result['createdAt'] = (int) $data['created_at'];

// toArray: local keys -> api keys
$result['created_at'] = (int) $data['createdAt'];

Full Example

Given this schema:

Message {
  id: uint64
  type: MessageType
  text: string?
  actor: User?
  unseen?: bool
  payload: map<string, string>
  createdAt: int
  @map.api(modified_at)
  updatedAt: int
}

MessageMapper.php:

<?php

namespace IntegrationEx\Mappers;

class MessageMapper
{
    public static function fromArray(array $data): array
    {
        $result = [];

        $result['id'] = (string) $data['id'];

        $result['type'] = $data['type'];

        $result['text'] = ($data['text'] !== null ? (string) $data['text'] : null);

        $result['actor'] = ($data['actor'] !== null ? UserMapper::fromArray($data['actor']) : null);

        if (array_key_exists('unseen', $data)) {
            $result['unseen'] = (bool) $data['unseen'];
        }

        $result['payload'] = array_map(fn($v) => (string) $v, $data['payload']);

        $result['createdAt'] = (int) $data['created_at'];

        $result['updatedAt'] = (int) $data['modified_at'];

        return $result;
    }

    public static function toArray(array $data): array
    {
        $result = [];

        $result['id'] = (string) $data['id'];

        $result['type'] = $data['type'];

        $result['text'] = ($data['text'] !== null ? (string) $data['text'] : null);

        $result['actor'] = ($data['actor'] !== null ? UserMapper::toArray($data['actor']) : null);

        if (array_key_exists('unseen', $data)) {
            $result['unseen'] = (bool) $data['unseen'];
        }

        $result['payload'] = array_map(fn($v) => (string) $v, $data['payload']);

        $result['created_at'] = (int) $data['createdAt'];

        $result['modified_at'] = (int) $data['updatedAt'];

        return $result;
    }
}

PHP SAMG Generator

The php.samg generator produces PHP SAMG (Schema Auto-Mapping Generation) v2 mapper classes with 10 static methods for bidirectional property mapping and type casting.

Configuration

[generate] = {
  [php.samg] = {
    output = 'output/php/SAMG/'
    namespace = 'App\\SAMG\\'
    map_from = self
    map_to = api
  }
}

Options

OptionRequiredDescription
outputYesOutput directory for generated .php files
namespaceNoPHP namespace prefix for generated classes
map_fromNoSource mapping context (default: self)
map_toNoTarget mapping context

CLI name: php.samg

vendor/bin/scsc gen php.samg schema.scsc --output=output/samg/

Output Files

One <Model>Map.php file per public model. Each class has 10 static methods organized into three categories: full mapping, partial mapping, and in-place casting.

The 10 Methods

Full Mapping (4 methods)

These methods map all properties, using ?? null for missing keys:

MethodDirectionType Casting
localToInterface(array $array): arrayLocal -> InterfaceYes
localToInterfaceMapOnly(array $array): arrayLocal -> InterfaceNo
interfaceToLocal(array $array): arrayInterface -> LocalYes
interfaceToLocalMapOnly(array $array): arrayInterface -> LocalNo

“MapOnly” variants only transform property keys without casting values. Useful when the data is already correctly typed and you only need name conversion.

Partial Mapping (4 methods)

These methods only map properties that exist in the input array, using array_key_exists guards. Ideal for PATCH-style updates:

MethodDirectionType Casting
localToPartialInterface(array $array): arrayLocal -> InterfaceYes
localToPartialInterfaceMapOnly(array $array): arrayLocal -> InterfaceNo
interfaceToPartialLocal(array $array): arrayInterface -> LocalYes
interfaceToPartialLocalMapOnly(array $array): arrayInterface -> LocalNo

In-Place Casting (2 methods)

These methods cast property values in-place by reference, without creating a new array or changing property keys:

MethodContext
castLocal(array &$array): voidCast values using local keys
castInterface(array &$array): voidCast values using interface keys

Example

Given this schema:

User {
  id: uint64
  name: string
}

UserMap.php:

<?php

namespace IntegrationEx\SAMG;

class UserMap
{
    /**
     * @param array<mixed> $array
     * @return array<mixed>
     */
    public static function localToInterface(array $array): array
    {
        return [
            'id' => (string) ($array['id'] ?? null),
            'name' => (string) ($array['name'] ?? null),
        ];
    }

    /**
     * @param array<mixed> $array
     * @return array<mixed>
     */
    public static function localToInterfaceMapOnly(array $array): array
    {
        return [
            'id' => $array['id'] ?? null,
            'name' => $array['name'] ?? null,
        ];
    }

    /**
     * @param array<mixed> $array
     * @return array<mixed>
     */
    public static function interfaceToLocal(array $array): array
    {
        return [
            'id' => (string) ($array['id'] ?? null),
            'name' => (string) ($array['name'] ?? null),
        ];
    }

    /**
     * @param array<mixed> $array
     * @return array<mixed>
     */
    public static function interfaceToLocalMapOnly(array $array): array
    {
        return [
            'id' => $array['id'] ?? null,
            'name' => $array['name'] ?? null,
        ];
    }

    /**
     * @param array<mixed> $array
     * @return array<mixed>
     */
    public static function localToPartialInterface(array $array): array
    {
        $buffer = [];
        if (array_key_exists('id', $array)) {
            $buffer['id'] = (string) ($array['id'] ?? null);
        }
        if (array_key_exists('name', $array)) {
            $buffer['name'] = (string) ($array['name'] ?? null);
        }
        return $buffer;
    }

    /**
     * @param array<mixed> $array
     * @return array<mixed>
     */
    public static function localToPartialInterfaceMapOnly(array $array): array
    {
        $buffer = [];
        if (array_key_exists('id', $array)) {
            $buffer['id'] = $array['id'];
        }
        if (array_key_exists('name', $array)) {
            $buffer['name'] = $array['name'];
        }
        return $buffer;
    }

    /**
     * @param array<mixed> $array
     * @return array<mixed>
     */
    public static function interfaceToPartialLocal(array $array): array
    {
        $buffer = [];
        if (array_key_exists('id', $array)) {
            $buffer['id'] = (string) ($array['id'] ?? null);
        }
        if (array_key_exists('name', $array)) {
            $buffer['name'] = (string) ($array['name'] ?? null);
        }
        return $buffer;
    }

    /**
     * @param array<mixed> $array
     * @return array<mixed>
     */
    public static function interfaceToPartialLocalMapOnly(array $array): array
    {
        $buffer = [];
        if (array_key_exists('id', $array)) {
            $buffer['id'] = $array['id'];
        }
        if (array_key_exists('name', $array)) {
            $buffer['name'] = $array['name'];
        }
        return $buffer;
    }

    /**
     * @param array<mixed> $array
     */
    public static function castLocal(array &$array): void
    {
        $array['id'] = (string) ($array['id'] ?? null);
        $array['name'] = (string) ($array['name'] ?? null);
    }

    /**
     * @param array<mixed> $array
     */
    public static function castInterface(array &$array): void
    {
        $array['id'] = (string) ($array['id'] ?? null);
        $array['name'] = (string) ($array['name'] ?? null);
    }
}

When to Use Which Method

Use CaseMethod
API response -> local storageinterfaceToLocal
Local data -> API requestlocalToInterface
PATCH update (only changed fields)localToPartialInterface / interfaceToPartialLocal
Key rename without casting (pre-casted data)*MapOnly variants
Normalize types on existing arraycastLocal / castInterface

Importers

Importers convert external format definitions into SchemaScript .scsc files. This lets you adopt SchemaScript incrementally by importing existing schema definitions.

Usage

vendor/bin/scsc import <importer> <file> [--output=<file.scsc>] [--stdout]

Options

OptionDescription
--output=<file>Write the generated .scsc to a file
--stdoutPrint the generated .scsc to stdout

List available importers:

vendor/bin/scsc --list-import

Available Importers

SAMG (samg)

The SAMG importer reads SAMG (Storage Abstraction Mapping Graph) markdown definition files and converts them into SchemaScript schemas.

vendor/bin/scsc import samg SAMG.md --output=schema.scsc

The importer:

  • Parses model definitions and their properties from the SAMG markdown format
  • Generates appropriate [map] and [generate] blocks
  • Configures snake_case mapping strategy for the API context
  • Sets up php.samg generator configuration with the namespace and output path from the SAMG metadata

Piping to a Generator

You can pipe imported output directly to a generator:

vendor/bin/scsc import samg SAMG.md --stdout | vendor/bin/scsc gen php.samg /dev/stdin --output=output/

This is useful for one-off generation without creating an intermediate .scsc file.