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.
How it works
- Parse — reads
CREATE TABLEstatements and builds a schema catalog. - Analyze — resolves every query's parameters and result columns against the catalog.
- Generate — emits one
readonlyDTO per table, PHP backed enums forENUMcolumns, 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"
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 type | PHP type | Notes |
|---|---|---|
INT, BIGINT, SMALLINT, TINYINT | int | |
DECIMAL, FLOAT, DOUBLE | float | |
VARCHAR, CHAR, TEXT | string | |
DATE, DATETIME, TIMESTAMP | string | Override with \DateTimeImmutable via type_overrides |
JSON | array | Hydrated via json_decode in fromRow |
ENUM(...) | EnumClass | Generates a PHP 8.1 backed enum file |
BOOLEAN | bool |
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
Return type semantics
| Annotation | PHP return type | Behaviour |
|---|---|---|
| :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;
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 expression | PHP type | Auto-alias (no AS) |
|---|---|---|
COUNT(*) | int | count |
SUM(int_col) | ?int | sumIntCol |
SUM(decimal_col) | ?float | sumDecimalCol |
AVG(col) | ?float | avgCol |
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(...) | ?string | concat |
CAST(x AS INT) | int | castX |
UPPER/LOWER/TRIM(col) | string | upper / lower / trim |
LENGTH(col) | int | length |
CASE WHEN ... | ?string | case |
| Unknown expression | mixed | col_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']),
{SingularTable}{PascalColumn} — e.g. orders.status → OrderStatus. Hyphenated values: in-progress → case 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,
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;
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_
) {}
}
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
| Operator | Rewritten 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) |
@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:
@paramannotation — explicit override:-- @param userId users.id- Qualified reference —
WHERE table.col = :param - SET clause —
SET col = :param - camelCase → snake_case —
:updatedAt→ looks upupdated_atin the schema - Fallback —
mixed/PDO::PARAM_STR
Generated file structure
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
| Suite | File | What it tests |
|---|---|---|
| Schema Parser | SchemaParserTest.php | CREATE TABLE, column types, nullability, AUTO_INCREMENT, ENUM |
| Query Parser | QueryParserTest.php | All annotations incl. @deprecated, @nillable, blank lines inside queries |
| Type Mapper | MySQLTypeMapperTest.php | Default mappings, nullability, PDO constants, overrides |
| JSON Type | JsonTypeTest.php | JSON → array mapping, json_decode casts in fromRow |
| Config | ConfigTest.php | YAML parsing, defaults, multiple query files, generate_interfaces |
| New Features v1.3 | NewFeaturesTest.php | Multiple schema files, nullable override, @deprecated, @nillable |
| New Features v1.4 | NewFeaturesV14Test.php | :many-paginated, @nillable on direct models, multiple targets, --dry-run, --diff |
| Embed | EmbedTest.php | @embed annotation, EmbedDefinition, EmbedGenerator, nested DTO generation |
| Param Resolver | ParamResolverTest.php | WHERE/SET resolution, camelCase→snake, fallback |
| Expression Resolver | ExpressionTypeResolverTest.php | COUNT/SUM/AVG/MIN/MAX/COALESCE/CAST/CASE alias and type |
| Analyzer | QueryAnalyzerTest.php | Full pipeline: model detection, JOIN DTOs, aggregates |
| SQL Rewriter | SqlRewriterTest.php | Optional param rewriting, all operators, unsafe construct guards |
| Optional Params | OptionalParamTest.php | Parser validation, analyzer marking, generator output |
| Enum Generator | EnumGeneratorTest.php | ENUM parsing, backed enum generation, fromRow casts |
| Interface Generator | InterfaceGeneratorTest.php | Interface generation, method signatures, implements clause |
| Generator | GeneratorTest.php | Generated code structure, docblock indentation, PDO bindings |
| Verify Flag | VerifyFlagTest.php | --verify exit codes, missing/modified detection, no file writes |
Project structure
Changelog
@embed— nested objects for JOIN results —-- @embed ClassName prefix_groups all columns whose alias starts withprefix_into a nestedreadonlyvalue object. The embedded class implementsfromRow(array $row): selfusing the original prefixed column names, so no extra queries are needed at runtime.EmbedDefinition— new value object (src/Parser/EmbedDefinition.php) withclassName,prefix, and helperspropertyName(),matches(),stripPrefix().EmbedGenerator— new generator (src/Generator/EmbedGenerator.php) that produces standalonereadonly classfiles for each@embedgroup.ResultDtoGeneratorupdated to partition result columns into embed groups and flat remainder;generate()return shape gains anembedskey.- Multiple embeds per query — stack multiple
@embedannotations on one query; each produces a separate file and a property on the parent DTO. - 26 new tests in
tests/EmbedTest.phpcovering all layers of the pipeline.
:many-paginatedreturn type — auto-injectsLIMIT :limit OFFSET :offsetinto the SQL and appendsint $limit = 20, int $offset = 0to the method signature. User params always appear first.@nillableon direct model queries — previously only worked on JOIN DTOs. Now, when used on a single-tableSELECT *, a dedicated*RowDTO is generated so nullability can be applied without mutating the base model.- Multiple output targets —
targets:block insqlc.yamlgenerates multiple namespaces and directories from the same schema in one run. Each target can have its own queries, interfaces flag, and type overrides. --dry-runflag — prints all generated file contents to stdout without writing anything to disk.--diffflag — shows a colored unified diff between current files and what would be generated. Exit 0 = no changes, 1 = changes found.- Parser fix —
@returnsregex now accepts hyphens, enabling:many-paginatedto parse correctly. - YAML
parseScalarfix — double-quoted strings now correctly unescape\\→\. - 33 new tests in
tests/NewFeaturesV14Test.phpcovering all five features end-to-end.
- Multiple schema files —
schemainsqlc.yamlnow 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 optionalnullable: true|falsefield that forces nullability regardless of the schema. Can be used alone to only change nullability while keeping the default type mapping. @deprecatedannotation — emits a@deprecatedPHPDoc tag on the generated method. Reason message is optional.@nillableannotation — 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.phpcovering all four features end-to-end.
- MySQL ENUM → PHP backed enum — generates a PHP 8.1 backed enum file.
fromRowuses::from()forNOT NULLand::tryFrom()for nullable. - JSON column → typed array —
JSONcolumns now map toarray.fromRowcallsjson_decode(..., true)with a?? []fallback. - Generate PHP interfaces —
generate_interfaces: truegenerates a*Interfacefile alongside each Query class. --verifyflag for CI — exits0when up to date,1otherwise. Reports missing and modified files. Writes nothing to disk.- 49 new tests across
EnumGeneratorTest,JsonTypeTest,InterfaceGeneratorTest, andVerifyFlagTest.
- Optional query parameters —
@optionalmarks parameters whose SQL condition is rewritten at generation time. Passingnullskips the filter entirely. SqlRewriter— rewritescol OP :paraminto(:param IS NULL OR col OP :param). Supports 9 operators.- Unsafe construct guard — queries with
JOIN,HAVING, or subqueries produce a fatal error when@optionalis used. - Parameter validation —
@optionalnames are validated against the SQL; typos produce a fatal error. - 34 new tests across
SqlRewriterTestandOptionalParamTest.
- Multiple query files —
queriesaccepts a scalar string or a YAML list of paths. - Expression type inference —
COUNT,SUM,AVG,MIN,MAX,COALESCE,IFNULL,NULLIF,CAST,CONCAT,CASE WHENresolved to typed PHP properties. :optreturn type —:onethrowsRuntimeException;:optreturnsnull.- Type overrides —
type_overridesremaps columns or DB types to arbitrary PHP types. - Initial release — schema parser, query parser, param/column resolvers, PDO bindings,
readonlyDTOs, result DTOs for JOINs and aggregates.