sqlc-php v1.5.0

sqlc-php

A PHP code generator inspired by sqlc for Go. Reads your SQL schema and annotated query files, and generates fully-typed PHP 8.4 classes using PDO — no ORM, no magic, just plain objects derived directly from your database.

PHP 8.3+ PDO Fully Typed No ORM

How it works

schema.sql + queries.sql + sqlc.yaml
            ↓
     sqlc-php (CLI)
            ↓
User.php · UserQuery.php · UserQueryInterface.php · OrderStatus.php
  1. Parse — reads CREATE TABLE statements and builds a schema catalog.
  2. Analyze — resolves every query's parameters and result columns against the catalog.
  3. Generate — emits one readonly DTO per table, PHP backed enums for ENUM columns, one query class per @group, and optionally a matching interface per query class.

Requirements

  • PHP 8.3+
  • PDO extension

Installation

composer require phpibe/sqlc-php

Then run the CLI from your project root:

php ./vendor/bin/sqlc-php sqlc.yaml            # generate files
php ./vendor/bin/sqlc-php --dry-run sqlc.yaml  # preview without writing
php ./vendor/bin/sqlc-php --diff    sqlc.yaml  # show what would change
php ./vendor/bin/sqlc-php --verify  sqlc.yaml  # CI check — exit 1 if stale

Configuration — sqlc.yaml

version: "1"
schema:  schema.sql    # single file (scalar) or list of files
queries: queries.sql   # single file (scalar) or list of files

php:
  namespace:           "App\\Database"  # PHP namespace for all generated classes
  out:                 generated        # output directory
  engine:              mysql            # database engine (mysql supported)
  generate_interfaces: true             # generate *Interface alongside each Query class

# Optional type overrides
type_overrides:
  - column:   "users.active"        # target a specific table.column
    php_type: "bool"

  - db_type:  "TINYINT"             # target every column of this SQL type
    php_type: "bool"

  - db_type:  "TIMESTAMP"
    php_type: "\\DateTimeImmutable"
    nullable: true                    # force nullable regardless of schema

Multiple schema files

schema accepts both a scalar string (single file) and a YAML list of paths. All files are parsed and merged into a single catalog before analysis:

schema:
  - database/schema/users.sql
  - database/schema/orders.sql
  - database/schema/roles.sql

Multiple query files

queries accepts both a scalar string and a YAML list of paths:

queries:
  - database/queries/users.sql
  - database/queries/roles.sql
  - database/queries/orders.sql

The CLI prints a per-file count alongside the total:

Schema : database/schema/users.sql
Schema : database/schema/orders.sql
Schema : 3 table(s) — users, orders, roles
Queries: 8 query(ies) from database/queries/users.sql
Queries: 3 query(ies) from database/queries/orders.sql
Queries: 11 total query(ies) parsed

Multiple output targets

targets lets you generate multiple namespaces and output directories from the same schema in a single run. Each target has its own namespace, out, queries, and optional overrides merged on top of the root-level ones:

version: "1"
schema:
  - database/schema/users.sql
  - database/schema/orders.sql

# Global overrides shared across all targets
type_overrides:
  - db_type: "TIMESTAMP"
    php_type: "\\DateTimeImmutable"
    nullable: true

targets:
  - namespace: "App\\Database\\Read"
    out:       generated/read
    queries:
      - database/queries/read/users.sql
      - database/queries/read/orders.sql
    generate_interfaces: true

  - namespace: "App\\Database\\Write"
    out:       generated/write
    queries:
      - database/queries/write/users.sql
    type_overrides:                    # merged on top of global overrides
      - column: "users.active"
        php_type: "bool"
When targets is absent, the root php: block is used as the single target. The two formats are fully backward compatible.

Type override precedence

Priority Rule Description
1 column Exact table.column match — wins over everything
2 db_type Matches any column whose SQL type matches
3 Default Built-in SQL → PHP type mapping

Nullable override

Any type_override entry accepts an optional nullable field that forces nullability regardless of the schema:

type_overrides:
  # Force nullable even though the column is NOT NULL in the schema
  - column:   "users.deleted_at"
    php_type: "\\Carbon\\Carbon"
    nullable: true

  # Force NOT nullable for all TIMESTAMP columns
  - db_type:  "TIMESTAMP"
    php_type: "\\DateTimeImmutable"
    nullable: false

  # Only change nullability, keep default type mapping
  - column:   "users.created_at"
    nullable: false

Default SQL → PHP type mapping

SQL typePHP typeNotes
INT, BIGINT, SMALLINT, TINYINTint
DECIMAL, FLOAT, DOUBLEfloat
VARCHAR, CHAR, TEXTstring
DATE, DATETIME, TIMESTAMPstringOverride with \DateTimeImmutable via type_overrides
JSONarrayHydrated via json_decode in fromRow
ENUM(...)EnumClassGenerates a PHP 8.1 backed enum file
BOOLEANbool

Annotating queries

Every query must have at minimum a @name and a @returns annotation, written as SQL comments:

-- @name    MethodName          required — PHP method name (camelCase)
-- @group   ClassName           optional — query class name; inferred from FROM table if omitted
-- @returns :many               required — :many | :many-paginated | :one | :opt | :exec
-- @param   userId users.id     optional — explicit type override for a named parameter
-- @optional paramName          optional — passing null skips the filter condition entirely
-- @deprecated reason           optional — marks the generated method as @deprecated
-- @nillable columnAlias        optional — forces a result column to be nullable in the DTO
-- @embed   ClassName prefix_   optional — groups prefixed columns into a nested object
@name required @returns required @group optional @param @optional @deprecated @nillable @embed

Return type semantics

AnnotationPHP return typeBehaviour
:many ModelClass[] Returns an array; empty array if no rows
:many-paginated ModelClass[] Like :many but auto-injects LIMIT :limit OFFSET :offset and adds $limit = 20, $offset = 0 params
:one ModelClass Returns the object; throws RuntimeException if no row found
:opt ModelClass|null Returns the object or null if no row found
:exec void Executes the statement (INSERT, UPDATE, DELETE)

SELECT * — returns the table model

-- @name ListUsers
-- @group User
-- @returns :many
SELECT users.* FROM users;

Generated method:

/** @return User[] */
public function listUsers(): array

SELECT with WHERE

-- @name GetUser
-- @group User
-- @returns :one
SELECT users.* FROM users WHERE users.id = :id;

-- @name GetUserByEmail
-- @group User
-- @returns :opt
SELECT users.* FROM users WHERE users.email = :email;
/** @return User */
public function getUser(?int $id): User               // throws RuntimeException if missing

/** @return User|null */
public function getUserByEmail(string $email): ?User  // returns null if missing

SELECT specific columns

When columns come from a single table, the return type is still the table model:

-- @name GetUserProfile
-- @group User
-- @returns :one
SELECT users.id, users.email, users.firstname, users.avatar
FROM users
WHERE users.id = :id;
public function getUserProfile(?int $id): User

JOIN — generates a result DTO

When columns come from multiple tables, a dedicated *Row DTO is generated:

-- @name GetUserWithRole
-- @group User
-- @returns :one
SELECT
    users.id,
    users.email,
    roles.name        AS role_name,
    roles.description AS role_description
FROM users
INNER JOIN roles ON roles.id = users.role_id
WHERE users.id = :id;
Generates GetUserWithRoleRow.php — readonly DTO with id, email, role_name, role_description.
public function getUserWithRole(?int $id): GetUserWithRoleRow

Aggregate and expression columns

sqlc-php infers types from SQL functions. Aliases are generated automatically when none is provided:

-- @name GetUserStats
-- @group User
-- @returns :one
SELECT
    COUNT(*)        AS total_users,
    SUM(active)     AS total_active,
    AVG(role_id)    AS avg_role,
    MAX(created_at) AS last_signup
FROM users;
readonly class GetUserStatsRow
{
    public function __construct(
        public int                 $total_users,   // COUNT → int, never null
        public ?int                $total_active,  // SUM   → ?int (null on empty set)
        public ?float              $avg_role,      // AVG   → ?float
        public ?\DateTimeImmutable $last_signup,   // MAX   → nullable, type from column
    ) {}
}

Expression type inference table

SQL expressionPHP typeAuto-alias (no AS)
COUNT(*)intcount
SUM(int_col)?intsumIntCol
SUM(decimal_col)?floatsumDecimalCol
AVG(col)?floatavgCol
MIN(col)?{type of col}minCol
MAX(col)?{type of col}maxCol
COALESCE(col, x){type of col} (not nullable)coalesceCol
IFNULL(col, x){type of col} (not nullable)ifnullCol
NULLIF(col, x)?{type of col}nullifCol
CONCAT(...)?stringconcat
CAST(x AS INT)intcastX
UPPER/LOWER/TRIM(col)stringupper / lower / trim
LENGTH(col)intlength
CASE WHEN ...?stringcase
Unknown expressionmixedcol_1, col_2…

UPDATE / DELETE — :exec

-- @name UpdateUserActive
-- @group User
-- @returns :exec
UPDATE users SET active = :active, updated_at = :updatedAt WHERE id = :id;

-- @name DeleteUser
-- @group User
-- @returns :exec
DELETE FROM users WHERE id = :id;
public function updateUserActive(?bool $active, ?string $updatedAt, ?int $id): void
public function deleteUser(?int $id): void

:many-paginated — automatic pagination

Using :many-paginated auto-appends LIMIT :limit OFFSET :offset to the SQL and adds those two parameters with sensible defaults:

-- @name ListUsers
-- @group User
-- @returns :many-paginated
SELECT users.* FROM users ORDER BY created_at DESC;

Generated method:

/**
 * @param int $limit  Maximum number of rows to return.
 * @param int $offset Number of rows to skip.
 * @return User[]
 */
public function listUsers(int $limit = 20, int $offset = 0): array

The SQL stored in the class becomes:

SELECT users.* FROM users ORDER BY created_at DESC
LIMIT :limit OFFSET :offset

User-defined parameters appear first; $limit and $offset are always last:

-- @name ListActiveUsers
-- @returns :many-paginated
-- @optional status
SELECT users.* FROM users WHERE users.status = :status;
public function listActiveUsers(?string $status = null, int $limit = 20, int $offset = 0): array

MySQL ENUM → PHP backed enum

CREATE TABLE orders (
    id     INT AUTO_INCREMENT PRIMARY KEY,
    status ENUM('pending', 'processing', 'completed', 'cancelled') NOT NULL
);
// OrderStatus.php — generated by sqlc-php
enum OrderStatus: string
{
    case Pending    = 'pending';
    case Processing = 'processing';
    case Completed  = 'completed';
    case Cancelled  = 'cancelled';
}

// in Order.php — fromRow() uses ::from() for NOT NULL, ::tryFrom() for nullable
OrderStatus::from((string) $row['status']),
Naming convention: {SingularTable}{PascalColumn} — e.g. orders.statusOrderStatus. Hyphenated values: in-progresscase InProgress.

JSON column → typed array

CREATE TABLE orders (
    metadata JSON null
);
// in Order.php
public ?array $metadata,

// in fromRow()
isset($row['metadata']) ? json_decode((string) $row['metadata'], true) : null,
For NOT NULL JSON columns, the fallback is ?? [] to guarantee a non-null array is always returned.

@deprecated — mark a method as deprecated

-- @name GetUser
-- @group User
-- @returns :one
-- @deprecated Use getUserById instead
SELECT users.* FROM users WHERE users.id = :id;
/**
 * @deprecated Use getUserById instead
 * @param ?int $id
 * @return User
 */
public function getUser(?int $id): User

The reason is optional — -- @deprecated without a message emits @deprecated alone.

@nillable — force a result column to be nullable

Useful in two scenarios:

LEFT JOIN — column may be NULL at runtime

-- @name GetUserWithOptionalRole
-- @group User
-- @returns :one
-- @nillable role_name
-- @nillable role_description
SELECT
    users.id,
    users.email,
    roles.name        AS role_name,
    roles.description AS role_description
FROM users
LEFT JOIN roles ON roles.id = users.role_id
WHERE users.id = :id;
readonly class GetUserWithOptionalRoleRow
{
    public function __construct(
        public ?int    $id,
        public string  $email,
        public ?string $role_name,         // forced nullable via @nillable
        public ?string $role_description,  // forced nullable via @nillable
    ) {}
}

Direct model queries (SELECT *)

When @nillable is used on a single-table SELECT * query, sqlc-php generates a dedicated *Row DTO instead of reusing the table model, so nullability can be applied without mutating the base model class:

-- @name GetUserProfile
-- @group User
-- @returns :one
-- @nillable email
SELECT users.* FROM users WHERE users.id = :id;
Generates GetUserProfileRow with public ?string $email — even though email is NOT NULL in the schema. The base User model is left unchanged.

@embed — nested objects for JOIN results

@embed ClassName prefix_ groups all result columns whose alias starts with prefix_ into a nested readonly value object instead of flattening them into the parent DTO.

-- @name GetUserWithRole
-- @group User
-- @returns :one
-- @embed Role role_
SELECT
    users.id,
    users.email,
    roles.name        AS role_name,
    roles.description AS role_description
FROM users
INNER JOIN roles ON roles.id = users.role_id
WHERE users.id = :id;

Generates two files:

Role.php — standalone readonly value object

readonly class Role
{
    public function __construct(
        public string  $name,
        public ?string $description,
    ) {}

    public static function fromRow(array $row): self
    {
        return new self(
            (string) $row['role_name'],         // uses original prefixed key
            $row['role_description'] ?? null,
        );
    }
}

GetUserWithRoleRow.php — parent DTO

readonly class GetUserWithRoleRow
{
    public function __construct(
        public ?int   $id,
        public string $email,
        public Role   $role,         // ← nested object, not $role_name + $role_description
    ) {}

    public static function fromRow(array $row): self
    {
        return new self(
            (int) $row['id'],
            (string) $row['email'],
            Role::fromRow($row),     // ← same flat PDO row, no extra query
        );
    }
}

Usage

$result = $repo->getUserWithRole(42);

echo $result->role->name;           // instead of $result->role_name
echo $result->role->description;

Multiple @embed groups

-- @name GetUserFull
-- @group User
-- @returns :one
-- @embed Role    role_
-- @embed Address addr_
SELECT
    users.id,
    users.email,
    roles.name       AS role_name,
    addresses.street AS addr_street,
    addresses.city   AS addr_city
FROM users
INNER JOIN roles     ON roles.id     = users.role_id
INNER JOIN addresses ON addresses.id = users.address_id
WHERE users.id = :id;

Generates Role.php, Address.php and GetUserFullRow.php:

readonly class GetUserFullRow
{
    public function __construct(
        public ?int     $id,
        public string   $email,
        public Role     $role,    // prefix: role_
        public Address  $addr,    // prefix: addr_
    ) {}
}
The DTO property name is derived from the prefix by stripping the trailing underscore: role_$role, addr_$addr, billing_$billing. The trailing underscore is optional in the annotation — @embed Role role and @embed Role role_ produce the same result.

Optional parameters

Marking a parameter as @optional rewrites the SQL condition at generation time. Passing null skips the filter entirely — no if statements or query builders required:

-- @name SearchUsers
-- @group User
-- @returns :many
-- @optional status
-- @optional username
SELECT users.* FROM users
WHERE users.status   = :status
  AND users.username = :username;

sqlc-php rewrites each optional condition before emitting any PHP:

-- rewritten SQL stored in the generated class
SELECT users.* FROM users
WHERE (:status   IS NULL OR users.status   = :status)
  AND (:username IS NULL OR users.username = :username)
/**
 * @param ?string $status   Pass null to skip this filter.
 * @param ?string $username Pass null to skip this filter.
 * @return User[]
 */
public function searchUsers(?string $status = null, ?string $username = null): array

Calling the method

// All rows — both filters skipped
$repo->searchUsers();

// Filter by status only — username skipped
$repo->searchUsers(status: 'active');

// Filter by both
$repo->searchUsers(status: 'active', username: 'alice');

Mixing required and optional parameters

Required parameters always appear first; optional parameters follow with = null:

// roleId is required, status is optional
public function getUsersByRole(int $roleId, ?string $status = null): array

Supported operators

OperatorRewritten form
=(:param IS NULL OR col = :param)
<>(:param IS NULL OR col <> :param)
!=(:param IS NULL OR col != :param)
>(:param IS NULL OR col > :param)
<(:param IS NULL OR col < :param)
>=(:param IS NULL OR col >= :param)
<=(:param IS NULL OR col <= :param)
LIKE(:param IS NULL OR col LIKE :param)
ILIKE(:param IS NULL OR col ILIKE :param)
Limitations: @optional is only safe on queries with a plain WHERE clause over a single table. Queries with JOIN, subqueries, or HAVING will produce a fatal error at generation time.

Parameter type resolution

Named parameters (:paramName) are automatically typed by matching them to schema columns. Resolution order:

  1. @param annotation — explicit override: -- @param userId users.id
  2. Qualified referenceWHERE table.col = :param
  3. SET clauseSET col = :param
  4. camelCase → snake_case:updatedAt → looks up updated_at in the schema
  5. Fallbackmixed / PDO::PARAM_STR

Generated file structure

generated/
  ├── OrderStatus.php # backed enum for orders.status ENUM column
  ├── Role.php # embedded value object from @embed Role role_
  ├── User.php # readonly DTO for the `users` table
  ├── Order.php # readonly DTO for the `orders` table
  ├── GetUserWithRoleRow.php # result DTO for a JOIN query with @embed
  ├── GetUserStatsRow.php # result DTO for an aggregate query
  ├── UserQuery.php # query class for the User group
  └── UserQueryInterface.php # interface (when generate_interfaces: true)

Model class example (User.php)

readonly class User
{
    public function __construct(
        public ?int    $id,
        public string  $email,
        public ?string $username,
        public ?bool   $active,        // overridden via type_overrides
        public int     $role_id,
        public ?string $created_at,
    ) {}

    public static function fromRow(array $row): self { ... }
}

Query class example (UserQuery.php)

class UserQuery implements UserQueryInterface
{
    public function __construct(private readonly PDO $pdo) {}

    /** @return User[] */
    public function listUsers(): array { ... }

    /** @return User[] */
    public function listUsersPaginated(int $limit = 20, int $offset = 0): array { ... }  // :many-paginated

    /** @return User */
    public function getUser(?int $id): User { ... }                     // :one — throws

    /** @return User|null */
    public function getUserByEmail(string $email): ?User { ... }        // :opt — nullable

    public function deleteUser(?int $id): void { ... }                  // :exec

    /** @return User[] */
    public function searchUsers(
        ?string $status   = null,   // @optional — pass null to skip filter
        ?string $username = null,   // @optional — pass null to skip filter
    ): array { ... }
}

Interface example (UserQueryInterface.php)

interface UserQueryInterface
{
    /** @return User[] */
    public function listUsers(): array;

    /** @return User */
    public function getUser(?int $id): User;

    /** @return User|null */
    public function getUserByEmail(string $email): ?User;

    public function deleteUser(?int $id): void;

    /**
     * @param ?string $status   Pass null to skip this filter.
     * @param ?string $username Pass null to skip this filter.
     * @return User[]
     */
    public function searchUsers(?string $status = null, ?string $username = null): array;
}

Usage in your application

$pdo  = new PDO('mysql:host=localhost;dbname=myapp', 'user', 'pass');
$repo = new UserQuery($pdo);

// :many — always an array
$users = $repo->listUsers();

// :one — throws RuntimeException if user not found
$user = $repo->getUser(42);

// :opt — returns null if not found
$user = $repo->getUserByEmail('alice@example.com');
if ($user === null) {
    // handle not found
}

// :exec — fire and forget
$repo->deleteUser(42);
$repo->updateUserActive(true, date('Y-m-d H:i:s'), 42);

// @optional — named arguments, skip filters by passing null
$all      = $repo->searchUsers();
$active   = $repo->searchUsers(status: 'active');
$filtered = $repo->searchUsers(status: 'active', username: 'alice');

Usage with Laravel

The recommended pattern is to wrap the generated query class inside a repository, bind it in a Service Provider using the generated interface, and inject it via the constructor.

1. Create a repository

namespace App\Repositories;

use App\Database\User;
use App\Database\UserQueryInterface;

class UserRepository
{
    public function __construct(private UserQueryInterface $userQuery) {}

    public function getUser(int $id): User
    {
        return $this->userQuery->getUser($id);
    }

    /** @return User[] */
    public function searchUsers(?string $status = null, ?string $username = null): array
    {
        return $this->userQuery->searchUsers(status: $status, username: $username);
    }
}

2. Register in a Service Provider

public function register(): void
{
    $this->app->bind(UserQueryInterface::class, function ($app) {
        return new UserQuery(
            $app->make('db')->connection()->getPdo()
        );
    });

    $this->app->bind(UserRepository::class, function ($app) {
        return new UserRepository(
            $app->make(UserQueryInterface::class)
        );
    });
}

3. Inject into a controller

class UserController extends Controller
{
    public function __construct(
        private readonly UserRepository $userRepository,
    ) {}

    public function show(int $id)
    {
        $user = $this->userRepository->getUser($id);
        return response()->json($user);
    }
}

5. Testing with the interface

public function test_show_returns_user(): void
{
    $mock = $this->createMock(UserQueryInterface::class);
    $mock->method('getUser')->willReturn(new User(
        id: 1, email: 'alice@example.com', username: 'alice',
        // ...
    ));

    $this->app->instance(UserQueryInterface::class, $mock);

    $this->getJson('/api/users/1')->assertOk();
}

CLI flags

--verify — CI check

Generates all files in memory and compares against the existing output. Writes nothing. Exits 1 if anything is missing or out of date.

# Add to your CI pipeline:
php vendor/bin/sqlc-php --verify sqlc.yaml

✓ UP TO DATE

✓ All 6 generated file(s) are up to date.

✗ STALE

✗ Generated files are out of date.

Missing files (1):
  - generated/OrderStatus.php

Modified files (1):
  - generated/User.php

Run `php vendor/bin/sqlc-php sqlc.yaml`
to regenerate.

--dry-run — preview without writing

Prints the full content of every file that would be generated to stdout. Writes nothing to disk.

php vendor/bin/sqlc-php --dry-run sqlc.yaml
──────────────────────────────────────────────────────────────────────
// generated/User.php
──────────────────────────────────────────────────────────────────────
<?php
declare(strict_types=1);
// ...

✓ Dry run complete. 4 file(s) would be written.

--diff — show what would change

Compares generated content against existing files and prints a colored unified diff. Exits 0 when nothing would change, 1 when there are differences. Writes nothing.

php vendor/bin/sqlc-php --diff sqlc.yaml
--- generated/User.php (current)
+++ generated/User.php (generated)
  public ?int $id,
- public string $email,
+ public ?string $email,
  public ?bool $active,

Running the tests

phpunit --configuration phpunit.xml
SuiteFileWhat it tests
Schema ParserSchemaParserTest.phpCREATE TABLE, column types, nullability, AUTO_INCREMENT, ENUM
Query ParserQueryParserTest.phpAll annotations incl. @deprecated, @nillable, blank lines inside queries
Type MapperMySQLTypeMapperTest.phpDefault mappings, nullability, PDO constants, overrides
JSON TypeJsonTypeTest.phpJSON → array mapping, json_decode casts in fromRow
ConfigConfigTest.phpYAML parsing, defaults, multiple query files, generate_interfaces
New Features v1.3NewFeaturesTest.phpMultiple schema files, nullable override, @deprecated, @nillable
New Features v1.4NewFeaturesV14Test.php:many-paginated, @nillable on direct models, multiple targets, --dry-run, --diff
EmbedEmbedTest.php@embed annotation, EmbedDefinition, EmbedGenerator, nested DTO generation
Param ResolverParamResolverTest.phpWHERE/SET resolution, camelCase→snake, fallback
Expression ResolverExpressionTypeResolverTest.phpCOUNT/SUM/AVG/MIN/MAX/COALESCE/CAST/CASE alias and type
AnalyzerQueryAnalyzerTest.phpFull pipeline: model detection, JOIN DTOs, aggregates
SQL RewriterSqlRewriterTest.phpOptional param rewriting, all operators, unsafe construct guards
Optional ParamsOptionalParamTest.phpParser validation, analyzer marking, generator output
Enum GeneratorEnumGeneratorTest.phpENUM parsing, backed enum generation, fromRow casts
Interface GeneratorInterfaceGeneratorTest.phpInterface generation, method signatures, implements clause
GeneratorGeneratorTest.phpGenerated code structure, docblock indentation, PDO bindings
Verify FlagVerifyFlagTest.php--verify exit codes, missing/modified detection, no file writes

Project structure

sqlc-php/
  ├── bin/
  │   └── sqlc-php # CLI: generate, --verify, --dry-run, --diff
  ├── src/
  │   ├── Analyzer/
  │   │   └── QueryAnalyzer.php # Enriches parsed queries with resolved types
  │   ├── Catalog/
  │   │   └── SchemaCatalog.php # In-memory table/column index
  │   ├── Config/
  │   │   ├── Config.php # YAML loader (schema/queries/targets lists)
  │   │   ├── Target.php # Single output target value object
  │   │   └── TypeOverride.php # php_type + nullable override
  │   ├── Generator/
  │   │   ├── EmbedGenerator.php # Generates nested value-object classes for @embed
  │   │   ├── EnumGenerator.php # PHP 8.1 backed enums for ENUM columns
  │   │   ├── InterfaceGenerator.php # *Interface alongside each Query class
  │   │   ├── ModelGenerator.php # Generates table DTO classes
  │   │   ├── QueryGenerator.php # Query classes with PDO methods
  │   │   └── ResultDtoGenerator.php # DTOs for JOIN/aggregate; handles @embed
  │   ├── Parser/
  │   │   ├── EmbedDefinition.php # Value object for @embed annotation
  │   │   ├── SchemaParser.php # Parses CREATE TABLE SQL
  │   │   └── QueryParser.php # Parses annotated SQL query files
  │   ├── Resolver/
  │   │   ├── ColumnResolver.php
  │   │   ├── ExpressionTypeResolver.php
  │   │   ├── ParamResolver.php
  │   │   ├── QueryParam.php
  │   │   └── ResolvedColumn.php
  │   ├── Rewriter/
  │   │   └── SqlRewriter.php # Rewrites optional param conditions
  │   └── TypeMapper/
  │       └── MySQLTypeMapper.php # Maps SQL types to PHP types and PDO constants
  ├── tests/ # PHPUnit test suite (352 tests)
  ├── sqlc.yaml # Example configuration
  └── phpunit.xml # Test configuration

Changelog

v1.5.0
  • @embed — nested objects for JOIN results-- @embed ClassName prefix_ groups all columns whose alias starts with prefix_ into a nested readonly value object. The embedded class implements fromRow(array $row): self using the original prefixed column names, so no extra queries are needed at runtime.
  • EmbedDefinition — new value object (src/Parser/EmbedDefinition.php) with className, prefix, and helpers propertyName(), matches(), stripPrefix().
  • EmbedGenerator — new generator (src/Generator/EmbedGenerator.php) that produces standalone readonly class files for each @embed group.
  • ResultDtoGenerator updated to partition result columns into embed groups and flat remainder; generate() return shape gains an embeds key.
  • Multiple embeds per query — stack multiple @embed annotations on one query; each produces a separate file and a property on the parent DTO.
  • 26 new tests in tests/EmbedTest.php covering all layers of the pipeline.
v1.4.0
  • :many-paginated return type — auto-injects LIMIT :limit OFFSET :offset into the SQL and appends int $limit = 20, int $offset = 0 to the method signature. User params always appear first.
  • @nillable on direct model queries — previously only worked on JOIN DTOs. Now, when used on a single-table SELECT *, a dedicated *Row DTO is generated so nullability can be applied without mutating the base model.
  • Multiple output targetstargets: block in sqlc.yaml generates multiple namespaces and directories from the same schema in one run. Each target can have its own queries, interfaces flag, and type overrides.
  • --dry-run flag — prints all generated file contents to stdout without writing anything to disk.
  • --diff flag — shows a colored unified diff between current files and what would be generated. Exit 0 = no changes, 1 = changes found.
  • Parser fix@returns regex now accepts hyphens, enabling :many-paginated to parse correctly.
  • YAML parseScalar fix — double-quoted strings now correctly unescape \\\.
  • 33 new tests in tests/NewFeaturesV14Test.php covering all five features end-to-end.
v1.3.0
  • Multiple schema filesschema in sqlc.yaml now accepts a scalar string or a YAML list. All files are merged into a single catalog before analysis.
  • Nullable override in type_overrides — entries accept an optional nullable: true|false field that forces nullability regardless of the schema. Can be used alone to only change nullability while keeping the default type mapping.
  • @deprecated annotation — emits a @deprecated PHPDoc tag on the generated method. Reason message is optional.
  • @nillable annotation — forces a specific result column to be ?type, regardless of the schema. Useful for LEFT JOIN queries. Multiple annotations can be stacked.
  • 33 new tests in tests/Config/NewFeaturesTest.php covering all four features end-to-end.
v1.2.0
  • MySQL ENUM → PHP backed enum — generates a PHP 8.1 backed enum file. fromRow uses ::from() for NOT NULL and ::tryFrom() for nullable.
  • JSON column → typed arrayJSON columns now map to array. fromRow calls json_decode(..., true) with a ?? [] fallback.
  • Generate PHP interfacesgenerate_interfaces: true generates a *Interface file alongside each Query class.
  • --verify flag for CI — exits 0 when up to date, 1 otherwise. Reports missing and modified files. Writes nothing to disk.
  • 49 new tests across EnumGeneratorTest, JsonTypeTest, InterfaceGeneratorTest, and VerifyFlagTest.
v1.1.0
  • Optional query parameters@optional marks parameters whose SQL condition is rewritten at generation time. Passing null skips the filter entirely.
  • SqlRewriter — rewrites col OP :param into (:param IS NULL OR col OP :param). Supports 9 operators.
  • Unsafe construct guard — queries with JOIN, HAVING, or subqueries produce a fatal error when @optional is used.
  • Parameter validation@optional names are validated against the SQL; typos produce a fatal error.
  • 34 new tests across SqlRewriterTest and OptionalParamTest.
v1.0.0
  • Multiple query filesqueries accepts a scalar string or a YAML list of paths.
  • Expression type inferenceCOUNT, SUM, AVG, MIN, MAX, COALESCE, IFNULL, NULLIF, CAST, CONCAT, CASE WHEN resolved to typed PHP properties.
  • :opt return type:one throws RuntimeException; :opt returns null.
  • Type overridestype_overrides remaps columns or DB types to arbitrary PHP types.
  • Initial release — schema parser, query parser, param/column resolvers, PDO bindings, readonly DTOs, result DTOs for JOINs and aggregates.