phpibe / sqlc-php
A PHP code generator inspired by sqlc
Requires
- php: >=8.4
- doctrine/inflector: ^2.0
- psr/log: ^1.0 || ^2.0 || ^3.0
- symfony/yaml: ^6.0 || ^7.0 || ^8.0
Requires (Dev)
- phpunit/phpunit: ^9.0 || ^13.1
This package is not auto-updated.
Last update: 2026-07-10 13:57:53 UTC
README
A PHP code generator inspired by sqlc for Go. It reads your SQL schema and annotated query files, and generates fully-typed PHP 8.4 classes that use PDO under the hood — no ORM, no magic, just plain objects derived directly from your database.
How it works
schema.sql + queries.sql + sqlc.yaml
↓
sqlc-php (CLI)
↓
User.php · UserQuery.php · UserQueryInterface.php · OrderStatus.php
- 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 php ./vendor/bin/sqlc-php --watch sqlc.yaml # watch for changes, auto-regenerate php ./vendor/bin/sqlc-php --watch --interval=250 sqlc.yaml # custom poll interval (ms) php ./vendor/bin/sqlc-php --version # print version and exit
Configuration — sqlc.yaml
version: "2" # Schema files — one or many (required) schema: - database/schema/users.sql - database/schema/orders.sql # Global defaults — inherited by all targets unless overridden locally engine: mysql # database engine (mysql supported; postgres planned for v1.7.0) language: english # english | spanish | french | portuguese | norwegian-bokmal | turkish # Global type overrides — applied to all targets type_overrides: - column: "users.active" php_type: "bool" - db_type: "TINYINT" php_type: "bool" # To use string instead of \DateTimeImmutable for date columns: # - db_type: "DATE" # php_type: "string" # Virtual tables — views or external tables not present in the schema files (optional) virtual_tables: - name: user_summary columns: - { name: id, type: INT } - { name: email, type: VARCHAR } - { name: role_name, type: VARCHAR, nullable: true } - { name: order_count, type: INT } # Include additional YAML fragments — each can contain virtual_tables, type_overrides, # and targets sections that are merged before the main file's values (optional) includes: - config/views.yaml - config/overrides.yaml # Output targets — one or more (required) targets: - namespace: "App\\Database" out: generated queries: - database/queries/users.sql - database/queries/orders.sql # generate_interfaces: true ← default, omit unless you want false # engine: mysql ← override global engine for this target # language: spanish ← override global language for this target # type_overrides: ← merged on top of global overrides # - column: "users.bio" # php_type: "string"
Minimal single-target config
version: "2" schema: schema.sql targets: - namespace: "App\\Database" out: generated queries: queries.sql
generate_interfaces defaults to true — interfaces are generated unless explicitly set to false:
targets: - namespace: "App\\Database" out: generated queries: queries.sql generate_interfaces: false # disable only if not needed
Multiple output targets
targets accepts any number of entries — each produces a separate generation pass using the same parsed schema:
version: "2" schema: - database/schema/users.sql - database/schema/orders.sql engine: mysql language: english type_overrides: - db_type: "TINYINT" php_type: "bool" targets: - namespace: "App\\Database\\Read" out: generated/read queries: - database/queries/read/users.sql - database/queries/read/orders.sql - namespace: "App\\Database\\Write" out: generated/write queries: - database/queries/write/users.sql generate_interfaces: false type_overrides: # merged on top of global overrides - column: "users.active" php_type: "bool"
Each target inherits the global engine, language, and type_overrides. A target can override any of them locally.
Multiple schema files
schema accepts both a scalar string (single file) and a YAML list. All files are parsed and merged into a single catalog:
schema: - database/schema/users.sql - database/schema/orders.sql - database/schema/roles.sql
Per-target queries
Each target has its own queries list. queries accepts a scalar or a list:
targets: - namespace: "App\\Database" out: generated 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
Target : App\Database → generated/
Queries: 8 query(ies) from database/queries/users.sql
Queries: 3 query(ies) from database/queries/orders.sql
Queries: 11 total
Inflection language
sqlc-php uses doctrine/inflector to singularise table names when inferring class names. Set globally or per-target:
language: spanish # global default targets: - namespace: "App\\Spanish" out: gen/es queries: queries/es.sql # inherits language: spanish - namespace: "App\\French" out: gen/fr queries: queries/fr.sql language: french # override for this target
With language: spanish, tables like usuarios, pedidos, categorias produce Usuario, Pedido, Categoria without needing @group on every query.
| Table name | english (doctrine) |
spanish |
|---|---|---|
analyses |
Analysis ✅ |
— |
matrices |
Matrix ✅ |
— |
usuarios |
Usuarios ❌ |
Usuario ✅ |
pedidos |
Pedido ✅ |
Pedido ✅ |
users |
User ✅ |
— |
The @group annotation always takes precedence over inferred names.
virtual_tables — views and external tables
virtual_tables: declares tables that exist in the database but have no CREATE TABLE in the schema files — views, materialized views, or tables from other schemas.
virtual_tables: - name: user_summary columns: - { name: id, type: INT } - { name: email, type: VARCHAR } - { name: role_name, type: VARCHAR, nullable: true } - { name: order_count, type: INT } - name: monthly_revenue columns: - { name: month, type: INT } - { name: revenue, type: DECIMAL }
Nullability convention — all columns are NOT NULL by default. Specify nullable: true only for columns that can be null. This is the inverse of schema parsing where NOT NULL must be explicit.
Virtual tables are registered in the SchemaCatalog for column type resolution. Queries against them work exactly like queries against real tables. The only difference: no Model class is generated for virtual tables.
-- @name ListUserSummaries -- @returns :many SELECT * FROM user_summary;
Generates UserSummaryQuery.php with correct column types, but no UserSummary.php model.
includes — splitting the config
includes: loads additional YAML fragments and merges their list fields (virtual_tables:, type_overrides:, targets:) into the main config. Scalar fields (engine:, language:) in include files are silently ignored.
# sqlc.yaml includes: - config/views/user_views.yaml - config/views/order_views.yaml - config/overrides/timestamps.yaml
# config/views/user_views.yaml virtual_tables: - name: user_summary columns: - { name: id, type: INT } - { name: email, type: VARCHAR }
# config/views/order_views.yaml virtual_tables: - name: order_summary columns: - { name: id, type: INT } - { name: total, type: DECIMAL }
All virtual_tables: entries from all includes are accumulated. Multiple include files can each declare their own virtual_tables: — they are all merged before processing.
| Field | Behaviour |
|---|---|
virtual_tables: |
Accumulated — all entries from all includes + main file |
type_overrides: |
Accumulated — includes first, main file appended last |
targets: |
Accumulated — includes first, main file appended last |
engine:, language: |
Ignored in includes — main file always controls scalars |
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:
type_overrides: - column: "users.deleted_at" php_type: "\\Carbon\\Carbon" nullable: true # force nullable even if NOT NULL in schema - db_type: "TIMESTAMP" php_type: "\\DateTimeImmutable" nullable: false # force not-null regardless of schema - column: "users.created_at" nullable: false # only change nullability, keep default type
When nullable is omitted, nullability is inherited from the schema.
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 |
\DateTimeImmutable |
fromRow uses new \DateTimeImmutable(...) |
TIME |
string |
no standard PHP time-interval type |
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 | :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 -- @dto ClassName optional — overrides the auto-generated DTO class name -- @column originalName alias optional — renames a result column in the DTO without SQL AS -- @calls method1,method2 optional — used with :transaction to list methods to call -- @counted optional — generate companion {name}Count(): int method (only with :many-paginated) -- @class ClassName sets the PHP class name (canonical, replaces @group) -- @group ClassName deprecated — use @class instead (still works, emits a warning)
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/OFFSET 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) |
:batch |
int |
Executes the same INSERT/UPDATE N times in a transaction; returns row count |
:transaction |
void |
Runs multiple @calls methods sequentially in one transaction |
Query examples
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 — :one throws, :opt returns null
-- @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;
Generated methods:
/** @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;
Generated files:
GetUserWithRoleRow.php— readonly DTO withid,email,role_name,role_description- Method in
UserQuery.php:
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 (mirroring sqlc/Go behaviour):
-- @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;
Generated DTO:
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… |
:many-paginated — automatic pagination
Using :many-paginated instructs sqlc-php to automatically append LIMIT :limit OFFSET :offset to the SQL and add those two parameters to the generated method 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
Any user-defined parameters appear first in the signature; $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
IN() clauses — array parameters
Parameters inside IN() clauses are automatically detected and handled with dynamic placeholder expansion at runtime. No manual SQL building required.
-- @name GetByIds -- @group User -- @returns :many SELECT users.* FROM users WHERE id IN (:ids);
Generated method:
/** * @param int[] $ids List of values for IN() clause — must be non-empty. * @return User[] */ public function getByIds(array $ids): array { // Expand IN() placeholders dynamically at runtime $__sql = 'SELECT * FROM users WHERE id IN (:ids)'; if (empty($ids)) { throw new \InvalidArgumentException('Parameter $ids for IN() clause must not be empty.'); } $__ph_ids = implode(',', array_fill(0, count($ids), '?')); $__sql = str_replace(':ids', $__ph_ids, $__sql); $stmt = $this->pdo->prepare($__sql); $stmt->execute([...$ids]); return array_map( static fn(array $row): User => User::fromRow($row), $stmt->fetchAll(PDO::FETCH_ASSOC), ); }
The element type in the docblock (int[]) is inferred from the column type, just like any other parameter.
Mixed IN and regular parameters
-- @name FilterUsers -- @returns :many SELECT users.* FROM users WHERE id IN (:ids) AND active = :active;
/** * @param int[] $ids List of values for IN() clause — must be non-empty. * @param int $active * @return User[] */ public function filterUsers(array $ids, int $active): array
Regular params are bound with bindValue(); IN-list params are expanded positionally and passed to execute(array). The two mechanisms are combined transparently.
Multiple IN clauses
-- @name FilterByIdsAndRoles -- @returns :many SELECT users.* FROM users WHERE id IN (:ids) AND role_id IN (:roleIds);
public function filterByIdsAndRoles(array $ids, array $roleIds): array
Each IN-list param gets its own placeholder variable ($__ph_ids, $__ph_roleIds) and its values are spread into execute() in order.
NOT IN
NOT IN (:param) works exactly like IN (:param):
SELECT users.* FROM users WHERE id NOT IN (:excludedIds);
public function excludeIds(array $excludedIds): array
:batch — bulk operations in a transaction
Executes the same INSERT or UPDATE query N times inside a single PDO transaction. Rolls back and re-throws on any failure.
-- @name InsertUsers -- @group User -- @returns :batch INSERT INTO users (email, username) VALUES (:email, :username);
$count = $userQuery->insertUsers([ ['email' => 'alice@example.com', 'username' => 'alice'], ['email' => 'bob@example.com', 'username' => 'bob'], ]); // → int (number of rows processed)
The statement is prepared once and reused for every row. An empty $rows array returns 0 without opening a transaction.
:transaction — multi-method transactions
Groups multiple :exec methods from the same Query class into a single transaction via @calls. Requires @group since there is no SQL to infer the group from.
-- @name TransferFunds -- @group Account -- @returns :transaction -- @calls debitAccount,creditAccount
// Wraps $this->debitAccount() and $this->creditAccount() in beginTransaction/commit/rollBack public function transferFunds(): void { ... }
If the :transaction method has @param declarations, they are forwarded to all callee methods.
Prepared statement caching
Opt-in per target. Caches PDOStatement objects to avoid re-preparing the same SQL on every call — especially useful in loops.
targets: - namespace: "App\\Database" out: generated queries: queries.sql prepared_statement_cache: true
With caching enabled, the generated class includes private array $stmts = [] and every method uses:
$stmt = $this->stmts[__FUNCTION__] ??= $this->pdo->prepare('SELECT ...');
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
MySQL ENUM → PHP backed enum
When a column is defined as ENUM(...), sqlc-php generates a PHP 8.1 backed enum file and uses it as the property type in the DTO. The fromRow method uses ::from() or ::tryFrom() depending on nullability.
CREATE TABLE orders ( id INT AUTO_INCREMENT PRIMARY KEY, status ENUM('pending', 'processing', 'completed', 'cancelled') NOT NULL );
Generated enum:
// OrderStatus.php — generated by sqlc-php enum OrderStatus: string { case Pending = 'pending'; case Processing = 'processing'; case Completed = 'completed'; case Cancelled = 'cancelled'; }
Generated DTO property and cast:
// in Order.php public OrderStatus $status, // in fromRow() OrderStatus::from((string) $row['status']),
Nullable ENUM columns use ::tryFrom():
public ?OrderStatus $status, // in fromRow() isset($row['status']) ? OrderStatus::tryFrom((string) $row['status']) : null,
Enum naming convention: {SingularTable}{PascalColumn} — e.g. orders.status → OrderStatus, users.role → UserRole. Hyphenated values are converted to PascalCase: in-progress → case InProgress = 'in-progress'.
JSON column → typed array
JSON columns map to array in PHP and are automatically hydrated via json_decode in the generated fromRow:
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
Adding @deprecated to a query causes the generated method to include a @deprecated PHPDoc tag. This is useful when migrating queries without breaking existing code.
-- @name GetUser -- @group User -- @returns :one -- @deprecated Use getUserById instead SELECT users.* FROM users WHERE users.id = :id;
Generated method:
/** * @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
@nillable columnAlias forces a specific column in the result set to be ?type in the generated DTO or return type, regardless of how the column is declared in the schema.
This is useful in two scenarios:
LEFT JOIN — column may be NULL at runtime even though NOT NULL in schema:
-- @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;
Generated DTO (multi-table → custom DTO):
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 *) — forces a dedicated DTO instead of reusing the table model:
When @nillable is used on a query that would normally return the table model directly (single-table SELECT *), sqlc-php generates a dedicated *Row DTO so the 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;
This generates GetUserProfileRow with public ?string $email instead of reusing User where email is NOT NULL.
Multiple @nillable annotations can be stacked. The annotation targets the output alias (the name after AS), or the column name when no alias is used.
@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;
Generated files:
Role.php — standalone readonly value object with stripped property names:
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'], $row['role_description'] ?? null, ); } }
GetUserWithRoleRow.php — parent DTO with the nested Role object as a property:
readonly class GetUserWithRoleRow { public function __construct( public ?int $id, public string $email, public Role $role, // ← nested object, not flat properties ) {} public static function fromRow(array $row): self { return new self( (int) $row['id'], (string) $row['email'], Role::fromRow($row), // ← hydrates from the same flat PDO row ); } }
Usage:
$result = $repo->getUserWithRole(42); echo $result->role->name; // instead of $result->role_name echo $result->role->description;
Multiple @embed groups on one query
-- @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 with:
public function __construct( public ?int $id, public string $email, public Role $role, // prefix: role_ public Address $addr, // prefix: addr_ ) {}
Naming convention
The DTO property name is derived from the prefix by stripping the trailing underscore:
role_→$roleaddr_→$addrbilling_→$billing
The prefix can be written with or without trailing underscore in the annotation:
@embed Role role_ and @embed Role role both produce the same result.
Optional parameters
Marking a parameter as @optional instructs sqlc-php to rewrite the SQL condition at generation time. When null is passed at runtime the filter is skipped entirely; when a value is passed it filters normally. 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)
Generated method:
/** * @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 in the signature; optional parameters follow with = null.
-- @name GetUsersByRole -- @group User -- @returns :many -- @optional status SELECT users.* FROM users WHERE users.role_id = :roleId AND users.status = :status;
// 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) |
Parameter name validation
If a name declared in @optional does not match any :param token in the SQL, generation stops immediately with a fatal error:
RuntimeException: Query 'SearchUsers': @optional 'stauts' does not match any
named parameter in the SQL. Known params: status, username
Limitations
@optional is only safe on queries with a plain WHERE clause over a single table. The following shapes produce a fatal error at generation time:
- JOIN clauses — params in
ONconditions would be rewritten incorrectly. - Subqueries — the rewriter cannot distinguish inner from outer
WHERE. - HAVING — semantically different from a row filter.
For these cases, use PHP-side conditional query building instead.
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
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 for UserQuery (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)
When generate_interfaces: true, the class declares implements UserQueryInterface:
class UserQuery implements UserQueryInterface { public function __construct(private readonly PDO $pdo) {} /** @return User[] */ public function listUsers(): array { ... } /** @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 class, bind it in a Service Provider using the generated interface, and inject it into controllers or services 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); } public function getUserByEmail(string $email): ?User { return $this->userQuery->getUserByEmail($email); } /** @return User[] */ public function searchUsers(?string $status = null, ?string $username = null): array { return $this->userQuery->searchUsers( status: $status, username: $username, ); } }
2. Register the binding in a Service Provider
namespace App\Providers; use App\Database\UserQuery; use App\Database\UserQueryInterface; use App\Repositories\UserRepository; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider { public function register(): void { // Bind the interface to the concrete implementation $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) ); }); } }
If your application uses multiple database connections, pass the connection name explicitly:
$app->make('db')->connection('mysql_replica')->getPdo()
3. Inject the repository into a controller
namespace App\Http\Controllers; use App\Repositories\UserRepository; 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); } public function index(Request $request) { $users = $this->userRepository->searchUsers( status: $request->query('status'), username: $request->query('username'), ); return response()->json($users); } }
4. Inject into a service or job
class SendWelcomeEmail implements ShouldQueue { public function __construct(private readonly UserRepository $userRepository) {} public function handle(): void { $user = $this->userRepository->getUserByEmail($this->email); // ... } }
5. Testing with the interface
Because the repository depends on UserQueryInterface, you can swap in a mock without touching the database:
class UserControllerTest extends TestCase { 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 them against the existing output. Writes nothing. Exits 1 if anything is missing or out of date.
php vendor/bin/sqlc-php --verify sqlc.yaml
✓ All 6 generated file(s) are up to date.
✗ 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
The test suite covers:
| Suite | File | What it tests |
|---|---|---|
| Schema Parser | tests/Parser/SchemaParserTest.php |
CREATE TABLE, ENUM, nullable, AUTO_INCREMENT, DEFAULT |
| Query Parser | tests/Parser/QueryParserTest.php |
All annotations incl. @deprecated, @nillable, blank lines |
| Type Mapper | tests/TypeMapper/MySQLTypeMapperTest.php |
Default mappings, nullable override, PDO constants |
| JSON Type | tests/TypeMapper/JsonTypeTest.php |
JSON → array, json_decode casts |
| Config | tests/Config/ConfigTest.php |
YAML parsing, scalar/list schema and queries, generate_interfaces |
| New Features v1.3 | tests/Config/NewFeaturesTest.php |
Multiple schemas, nullable override, @deprecated, @nillable |
| New Features v1.4 | tests/NewFeaturesV14Test.php |
:many-paginated, @nillable on direct models, targets, --dry-run, --diff |
| Embed | tests/EmbedTest.php |
@embed annotation, EmbedDefinition, EmbedGenerator, nested DTO generation |
| Inflector | tests/InflectorServiceTest.php |
InflectorService, all 6 languages, Config language field, group inference |
| Bug Fixes | tests/BugFixTest.php |
Regression tests for v1.5.2 critical and medium bug fixes |
| IN() Params | tests/InListParamTest.php |
IN/NOT IN detection, type inference, signature, placeholder expansion |
| TypeMapper Factory | tests/TypeMapper/TypeMapperFactoryTest.php |
Interface contract, factory engine resolution, unsupported engine errors |
| Param Resolver | tests/Resolver/ParamResolverTest.php |
WHERE/SET/UPDATE param resolution, camelCase→snake |
| Expression Resolver | tests/Resolver/ExpressionTypeResolverTest.php |
All aggregate and scalar functions |
| Analyzer | tests/Analyzer/QueryAnalyzerTest.php |
Full pipeline: model detection, JOINs, aggregates |
| SQL Rewriter | tests/Rewriter/SqlRewriterTest.php |
All operators, unsafe construct guards |
| Optional Params | tests/Analyzer/OptionalParamTest.php |
@optional end-to-end |
| Enum Generator | tests/Generator/EnumGeneratorTest.php |
ENUM parsing, backed enum generation, fromRow casts |
| Interface Generator | tests/Generator/InterfaceGeneratorTest.php |
Interface code, method signatures, implements clause |
| Generator | tests/Generator/GeneratorTest.php |
Code structure, docblock indentation, PDO bindings |
| Verify Flag | tests/VerifyFlagTest.php |
--verify exit codes, no file writes |
Project structure
sqlc-php/
├── bin/
│ └── sqlc-php # CLI entry point (supports --verify)
├── 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 # Generates PHP 8.1 backed enums for ENUM columns
│ │ ├── InterfaceGenerator.php # Generates *Interface alongside each Query class
│ │ ├── ModelGenerator.php # Generates table DTO classes
│ │ ├── QueryGenerator.php # Generates query classes with PDO methods
│ │ └── ResultDtoGenerator.php # Generates result DTOs; handles @embed partitioning
│ ├── Inflector/
│ │ └── InflectorService.php # doctrine/inflector wrapper with fallback
│ ├── Parser/
│ │ ├── EmbedDefinition.php # Value object for @embed annotation
│ │ ├── SchemaParser.php # Parses CREATE TABLE SQL (including ENUM values)
│ │ └── QueryParser.php # Parses annotated SQL query files
│ ├── Resolver/
│ │ ├── ColumnResolver.php # Resolves SELECT columns to typed ResolvedColumn objects
│ │ ├── ExpressionTypeResolver.php # Infers types of SQL functions and expressions
│ │ ├── ParamResolver.php # Infers types of named :parameters
│ │ ├── QueryParam.php # Value object for a resolved parameter
│ │ └── ResolvedColumn.php # Value object for a resolved output column
│ ├── Rewriter/
│ │ └── SqlRewriter.php # Rewrites optional param conditions in SQL
│ └── TypeMapper/
│ ├── TypeMapperInterface.php # Contract all engine mappers must implement
│ ├── TypeMapperFactory.php # Resolves mapper by engine (mysql → MySQLTypeMapper)
│ └── MySQLTypeMapper.php # MySQL: SQL types → PHP types + PDO constants
├── tests/ # PHPUnit test suite (573 tests)
├── sqlc.yaml # Example configuration
└── phpunit.xml # Test configuration
Changelog
[2.13.1] — Hotfix: CTE feature crashed in real-world CLI usage
Fixes two regressions introduced in 2.13.0 that only manifest when running bin/sqlc-php for real (not caught by the unit test suite, which exercised the classes directly rather than through the CLI entrypoint):
Bug 1 — Undefined variable $baseDir, fatal TypeError on every run, even without ctes: configured.
runGeneration() referenced $baseDir when calling CteRegistry::build(), but the variable was never defined anywhere in the function. This broke every generation run, regardless of whether the project used CTEs at all.
Fix: $baseDir is now derived from the resolved config path: dirname(realpath($configPath) ?: $configPath), matching the same convention Config::fromFile() already uses internally for includes: and virtual_tables:.
Bug 2 — Fatal Unknown named parameter $returnType whenever a query actually used @use.
The QueryDefinition reconstruction (done to inject the resolved WITH clause) referenced a non-existent returnType parameter — the real property is returns — and omitted several other required properties (resultColumns, paramAnnotations, optionalParams, returnsModelDirectly, modelClass, deprecated, nillableColumns), which would have silently reset them to defaults even if the name had been correct.
Fix: the reconstruction now lists every real QueryDefinition property with its correct name.
Both bugs were latent in the unit tests because tests called CteRegistry and QueryDefinition directly with controlled inputs, never exercising the exact code path inside bin/sqlc-php. 6 new end-to-end CLI regression tests (tests/CliCteRegressionTest.php) now invoke the actual bin/sqlc-php binary as a subprocess against real temporary project directories, covering: no ctes: configured, @use present, mixed queries with and without @use, and CLI output reporting of loaded CTE names.
No changes to the public API or query/CTE syntax — this is a pure bugfix release.
Introduces reusable named CTEs that can be declared once in dedicated .sql files and referenced by any number of queries via @use. Eliminates the need to repeat WITH ... AS (...) blocks across queries that share the same CTE logic.
CTE file format (database/ctes/active_users.sql):
-- @cte active_users SELECT id, email, role FROM users WHERE active = 1 AND role = 'client'; -- @cte active_admins SELECT id, email FROM users WHERE active = 1 AND role = 'admin';
Each file can contain any number of @cte name blocks separated by the annotation. A file without any @cte annotation is silently ignored.
sqlc.yaml configuration:
version: 1 engine: mysql schema: - database/schema.sql ctes: # global — available to all targets - database/ctes/active_users.sql - database/ctes/recent_orders.sql targets: - namespace: App\Queries queries: - database/queries/orders.sql out: generated/ ctes: # per-target — merged with global - database/ctes/billing.sql
Usage in queries:
-- @name ListActiveUserOrders -- @class Orders -- @returns :many -- @use active_users SELECT orders.* FROM orders INNER JOIN active_users ON active_users.id = orders.user_id; -- @name ListActiveUserPayments -- @class Payments -- @returns :many -- @use active_users, recent_orders ← multiple CTEs, comma-separated SELECT payments.* FROM payments INNER JOIN active_users ON active_users.id = payments.user_id INNER JOIN recent_orders ON recent_orders.id = payments.order_id;
Generated SQL (injected automatically before analysis):
WITH active_users AS ( SELECT id, email, role FROM users WHERE active = 1 AND role = 'client' ), recent_orders AS ( SELECT id, user_id FROM orders WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) ) SELECT payments.* FROM payments ...
Rules and constraints:
- CTE names must be unique across all loaded files — a duplicate triggers a clear error.
- Queries that use
@usemust not declare an inlineWITHclause — mixing the two is an error. - Multiple
@useon the same query and comma-separated names on one line are both supported. - Duplicate names in a single
@uselist are deduplicated automatically. - Global
ctes:paths are available to every target. Per-targetctes:add to them. - CTE files are pure SQL — no PHP or YAML mixed in.
- Injected CTEs are transparent to the Analyzer, Resolver, and Generator — no other changes needed.
New classes:
src/Parser/CteDefinition.php— readonly value object{name, sql, sourceFile}src/Parser/CteParser.php— parses@cteblocks from.sqlfilessrc/Config/CteRegistry.php— registry with duplicate detection andinject()method
Modified:
-
src/Parser/QueryParser.php—$usedCtes: string[]field,@useannotation parsing -
src/Analyzer/QueryAnalyzer.php— propagatesusedCtes -
src/Config/Config.php—$globalCtePaths, parses rootctes:key -
src/Config/Target.php—$ctePaths, parses targetctes:key -
bin/sqlc-php— buildsCteRegistryper-target, injects CTEs before analysis, logs loaded CTEs -
31 new tests in
tests/SharedCteTest.php
Extends the @json annotation with explicit cardinality variants:
| Syntax | Cardinality | Use case | Generated type |
|---|---|---|---|
@json alias ClassName |
many (default) | JSON_ARRAYAGG(...) |
ClassName[] |
@json:many alias ClassName |
many (explicit) | JSON_ARRAYAGG(...) |
ClassName[] |
@json:one alias ClassName |
one | JSON_OBJECT(...) |
ClassName |
@json:one — single embedded object:
-- @name GetUserWithAddress -- @class User -- @returns :one -- @json:one address Address SELECT users.id, users.name, JSON_OBJECT('id', addresses.id, 'street', addresses.street, 'city', addresses.city) AS address FROM users INNER JOIN addresses ON addresses.user_id = users.id WHERE users.id = :id;
Generated DTO:
readonly class GetUserWithAddressRow { public function __construct( public int $id, public string $name, public Address $address, // ← typed single object, not array ) {} public static function fromRow(array $row): self { return new self( id: (int) $row['id'], name: (string) $row['name'], address: Address::fromRow(json_decode((string) $row['address'], true) ?? []), ); } }
Mixed cardinality on the same query:
-- @json:one address Address ← single JSON_OBJECT -- @json:many orders Order ← array from JSON_ARRAYAGG
Backward compatibility: bare @json alias ClassName continues to behave as :many — no changes required to existing queries.
- 14 new tests in
tests/JsonCardinalityTest.php
Fixes a MySQL "Column 'x' in where clause is ambiguous" error that occurred when using @searchable on queries that JOIN multiple tables sharing a column name (e.g. reserve_id present in reserves, products, and payments).
Root cause: CriteriaGenerator was using the result alias as the column reference inside Filter::eq('reserve_id', ...). MySQL cannot resolve bare column names in WHERE when they exist in more than one joined table.
Fix: When a ResolvedColumn has both tableName and columnName populated (i.e. it comes from a real schema table), the generated Filter now uses table.column as the column reference:
// Before (ambiguous on JOIN): Filter::eq('reserve_id', $value) Filter::eq('product_reserve_id', $value) // alias — doesn't exist as a column name // After (always unambiguous): Filter::eq('reserves.reserve_id', $value) Filter::eq('products.reserve_id', $value) // resolved from tableName.columnName
What does NOT change:
- Method names still derive from the result alias:
whereReserveIdEq(),whereProductReserveIdEq() orderBy*()methods still use the alias — MySQLORDER BYacceptsSELECTaliasesCOLUMN_*constants andallowedColumnsstill use the alias- Expression/aggregate columns (
COUNT(*),SUM(...),JSON_ARRAYAGG(...)) have notableNameand continue using the alias as-is
Single-table queries are unaffected — they are also qualified now (users.id instead of id) which is always valid SQL and slightly more explicit.
- 13 new tests in
tests/CriteriaFilterColumnTest.php
Adds a new @json alias ClassName annotation that deserializes a JSON_ARRAYAGG result column into a typed ClassName[] array instead of a plain PHP array. A standalone readonly DTO is always generated for the referenced class, inferred from the schema table whose name matches the class (e.g. City → cities, Country → countries).
Usage:
-- @name GetCountryWithCities -- @class Country -- @returns :one -- @json cities City SELECT countries.id, countries.name, JSON_ARRAYAGG( JSON_OBJECT('id', cities.id, 'name', cities.name) ) AS cities FROM countries INNER JOIN cities ON cities.country_id = countries.id WHERE countries.id = :id GROUP BY countries.id;
Generated parent DTO (GetCountryWithCitiesRow.php):
readonly class GetCountryWithCitiesRow { public function __construct( public int $id, public string $name, /** @var City[] */ public array $cities, ) {} public static function fromRow(array $row): self { return new self( id: (int) $row['id'], name: (string) $row['name'], cities: array_map(fn(array $r) => City::fromRow($r), json_decode((string) $row['cities'], true) ?? []), ); } }
Generated JSON DTO (City.php) — inferred from cities table schema:
readonly class City { public function __construct( public int $id, public string $name, public string $slug, public int $country_id, ) {} public static function fromRow(array $row): self { ... } }
Key behaviours:
- The
CityDTO is always generated — it is never reused from an existing model class. - Placement respects
scoped_dtos:— whentrue, the DTO lives inDTOs/{Group}/{Method}/City.php; whenfalse, it is placed flat in the DTOs directory. - When
extensions:is declared inout:, aCityExtensiontrait scaffold is generated alongside the DTO (write-once, never overwritten), with@propertytags for all schema columns. The parent DTO extension also lists the@jsoncolumn as@property array $cities. - Error on unknown table — if no schema table can be matched to the class name (direct, lowercase, or plural heuristic), generation fails with a clear
RuntimeException. - Multiple
@jsonannotations on the same query are supported — each produces its own DTO and extension. - 26 new tests in
tests/JsonAnnotationTest.php.
Iterates the scoped_dtos: true feature. The subdirectory now includes the @class group as a parent:
Before (v2.9.2): DTOs/GetDetails/ReserveBilling.php
After (v2.9.3): DTOs/ReserveBilling/GetDetails/ReserveBilling.php
↑ Group ↑ Method
This matches the exact structure from the feature request:
-- @name GetDetails -- @class ReserveBilling -- @dto ReserveBilling -- @embed ReserveBillingCustomer customer__ -- @embed ReserveBillingProduct product__ -- @embed ReserveBillingReserve reserve__ -- @returns :one
Generates:
DTOs/ReserveBilling/GetDetails/ReserveBilling.php
DTOs/ReserveBilling/GetDetails/ReserveBillingCustomer.php
DTOs/ReserveBilling/GetDetails/ReserveBillingProduct.php
DTOs/ReserveBilling/GetDetails/ReserveBillingReserve.php
Also enables two groups with the same method name to coexist:
DTOs/Billing/GetDetails/BillingReserve.php ← App\DTOs\Billing\GetDetails
DTOs/Reserve/GetDetails/BillingReserve.php ← App\DTOs\Reserve\GetDetails
[2.9.2] — scoped_dtos & embed collision detection
Embed collision detection: when two queries use @embed with the same class name but different columns, generation now aborts with a clear error instead of silently overwriting:
Error: @embed class 'BillingReserve' is declared in multiple queries with different column shapes.
getBillingDetails → id:int, total_price:float
getBillingWithDate → id:int, created_at:DateTimeImmutable
Solutions:
1. Enable scoped_dtos: true in sqlc.yaml
2. Use distinct class names
3. Use the same columns in both queries
scoped_dtos: true: each query's DTOs and embeds get a dedicated subdirectory named after the method. Collisions become structurally impossible:
targets: - namespace: "App\Database" scoped_dtos: true out: dtos: app/Database/DTOs
DTOs/GetBillingDetails/BillingDetails.php ← App\Database\DTOs\GetBillingDetails
DTOs/GetBillingDetails/BillingReserve.php ← same namespace
DTOs/GetBillingWithDate/BillingWithDate.php ← App\Database\DTOs\GetBillingWithDate
DTOs/GetBillingWithDate/BillingReserve.php ← different namespace, no collision
Backward compatible — scoped_dtos: false by default. 16 new tests.
[2.9.1] — table.* with @embed bugfix
Bug fix: using reserve_billing.* alongside @embed columns with __ prefixes now generates the correct return type.
Root cause: detectDirectModel counted reserve__id (from reserve table) as proof of multiple tables, discarding the @dto annotation and falling back to GetDetailsRow.
Fix: columns with __ in their alias are excluded from the single-table check — they are embedded object fields by design. @embed still forces DTO mode (the plain model doesn't have nested properties), but the @dto class name is correctly used as the return type.
-- @name GetDetails -- @class ReserveBilling -- @dto ReserveBilling ← now correctly used as return type -- @embed ReserveBillingReserve reserve__ -- @returns :one SELECT reserve_billing.*, -- expands all billing columns reserve.id as reserve__id, -- __ prefix = @embed, excluded from model check reserve.created_at as reserve__created_at FROM reserve_billing INNER JOIN reserve ON reserve_billing.reserve_id = reserve.id WHERE reserve_billing.reserve_id = :id
13 new tests in tests/TableWildcardEmbedTest.php.
[2.9.0] — OR groups in Criteria & UNION queries
Criteria::orGroup() — adds OR conditions to @searchable criteria. Fully immutable, backward compatible.
(new UserCriteria()) ->whereActiveEq(1) ->orGroup(fn($c) => $c->whereCountryIdEq(164)) ->orGroup(fn($c) => $c->whereCountryIdEq(165)) // WHERE active = :active_f0 OR country_id = :country_id_f1 OR country_id = :country_id_f2
UNION / UNION ALL — natively supported. Types resolved from first SELECT. @searchable, @partial, @returning rejected on UNION with clear errors.
35 new tests in tests/OrGroupUnionTest.php.
[2.8.5] — Technical debt refactor
Three structural improvements with no user-facing behavior changes — same SQL output, same generated code, better internals.
Fix A — renderPaginateCore(): the duplicated COUNT+SELECT body (∼50 lines) that appeared in both renderPaginateMethod and renderSearchablePaginateMethod is now a single shared method. Both entry points pass their specific SQL expressions and binding blocks as parameters.
Fix B — InterfaceGenerator strategy dispatch: the monolithic renderMethodSignature() with 7 if/elseif branches is replaced by a match() dispatch table routing to one dedicated renderer per return-type family. Adding a new return type now means adding one method — the router never changes.
Fix C — renderBindings(string $stmtVar = '$stmt'): root cause of the $stmt undefined bug in :paginated. renderBindings() now accepts the PDO statement variable name explicitly. The :paginated methods call renderBindings($query, '$__countStmt') and renderBindings($query, '$__stmt') directly — the str_replace('$stmt->', ...) workaround is gone.
23 new tests in tests/TechDebtRefactorTest.php.
[2.8.0] — :paginated & @returning
:paginated — new return type (alongside :many, :one, etc.) that returns a PaginatedResult with items + metadata in one call:
-- @name ListUsers -- @returns :paginated ← limit defaults to 10, runs COUNT + SELECT SELECT * FROM users WHERE active = :active ORDER BY created_at DESC;
$result = $query->listUsers(active: 1, limit: 20, offset: 0); $result->items; // User[] — current page $result->total; // 150 — total matching rows $result->pages; // 8 $result->hasMore; // true $result->nextOffset(); // 20
@returning — INSERT that fetches and returns the created row:
-- @name CreateUser -- @returning -- @returns :one INSERT INTO users (email, active) VALUES (:email, :active);
$user = $query->createUser(email: 'alice@example.com', active: 1); echo $user->id; // auto-increment PK from lastInsertId()
Other changes:
SchemaCatalog::primaryKey()— detects PK fromPRIMARY KEY,AUTO_INCREMENT, or columnidColumnDefinition::$isPrimaryKey— new field from schema parserSqlcPhp\Query\PaginatedResult— new runtime readonly class with navigation helpers- 53 new tests in
tests/PaginateReturningTest.php
[2.7.7] — toDebugBindings() for Debugbar integration
QueryObject::toDebugBindings(): list<mixed>— flat array of values forQueryExecuted/ DebugbarQueryCollector. Filters_chkparams (@optional) and:limit/:offset(:many-paginated).
The bug: passing $q->bindings() directly to Debugbar showed [,1] because bindings() returns [value, PDO_TYPE] tuples — Debugbar serialized the inner array as a string.
Fix — Option A (recommended): toDebugSql() + empty bindings:
// ServiceProvider $this->app->bind(BillingConfigRepositoryInterface::class, function ($app) { return new BillingConfigRepository( pdo: $app->make('db')->connection()->getPdo(), afterQuery: function (QueryObject $q) use ($app): void { $collector = \Debugbar::getCollector('queries'); $qe = new QueryExecuted( $q->toDebugSql(), // SQL with values already interpolated [], $q->durationMs, $app->make('db')->connection(), ); $collector->addQuery($qe); }, ); });
Fix — Option B: toString() + toDebugBindings():
$qe = new QueryExecuted( $q->toString(), // SQL with :placeholders $q->toDebugBindings(), // [1, 164] — flat, _chk filtered $q->durationMs, $connection, );
- 17 new tests in
tests/DebugBindingsTest.php.
[2.7.6] — query execution timing (durationMs)
QueryObject::$durationMs— every method wraps$stmt->execute()withhrtime(true)and stores the elapsed milliseconds.QueryObject::withDuration(float $ms): self— immutable named constructor for setting duration.- Log format updated —
"listActiveUsers [4.217ms]: SELECT * FROM users WHERE ...". :batchtiming — measures full transaction (all rows + commit).- 21 new tests in
tests/DurationTest.php.
$repo->listActiveUsers(active: 1); $q = $repo->lastQuery(); echo $q->durationMs; // 4.217 — float milliseconds // Slow query detection new UserRepository( pdo: $pdo, afterQuery: function (QueryObject $q): void { if ($q->durationMs > 100) { Log::warning("Slow: {$q->queryName} took {$q->durationMs}ms"); } }, );
[2.7.5] — PSR-3 logger & afterQuery hook
- Constructor updated — every generated Query class now accepts
?LoggerInterface $logger = nulland?Closure $afterQuery = null. Fully backward compatible.
$repo = new UserRepository( pdo: $pdo, logger: app(LoggerInterface::class), // PSR-3 → Telescope / files afterQuery: fn(QueryObject $q) => \Debugbar::addMessage($q->toString(), 'queries'), // Debugbar );
- PSR-3 logger — every executed method calls
$logger->debug(queryName + SQL, values). Works with Monolog, Laravel Log, Symfony Logger. Appears in Telescope Logs tab. - afterQuery hook —
Closurecalled after every query withQueryObject. Use for Debugbar, OpenTelemetry, metrics, per-request query collection. psr/log: ^1.0 || ^2.0 || ^3.0added as a dependency.- 22 new tests in
tests/LoggerHookTest.php.
[2.7.4] — lastQuery() inspection
lastQuery(): ?QueryObject— every generated Query class records the SQL and bound parameters of the most recently executed method.
$users = $repo->listActiveUsers(active: 1); $q = $repo->lastQuery(); echo $q->toString(); // SQL with placeholders — safe to log echo $q->toDebugSql(); // SQL with values — debug only $key = $q->cacheKey(); // stable md5 for caching $q->values(); // bound values as array
QueryObject— readonly value object withtoString(),toDebugSql(),bindings(),values(),cacheKey(),paramCount(). Lives inSqlcPhp\Query\QueryObject.Criteria::getBindings()— new method exposing filter bindings as array; enables@searchablequeries to correctly populatelastQuery.- Not in interface —
lastQuery()is excluded from*Interface.php— it's infrastructure, not domain contract. - 43 new tests in
tests/LastQueryTest.php.
[2.7.3] — out: map form (per-type directories)
out:now accepts a YAML map — each file type gets its own output directory and namespace, enabling DDD layouts likeDatabase/Repositories,Database/Models,Database/DTOs.
targets: - namespace: "App\\Database" queries: queries.sql out: queries: database/Repositories # → App\Database\Repositories\UserRepository.php models: database/Models # → App\Database\Models\User.php dtos: database/DTOs # → App\Database\DTOs\GetUserWithRoleRow.php enums: database/Enums interfaces: database/Contracts criterias: database/Criterias
- Namespace derivation —
namespace + '\' + last path segment. No extra config. - Automatic
usestatements — injected where needed when namespaces differ. - Backward compatible —
out: generated(string form) still works exactly as before. - Error on missing type — generation fails with a clear message if a needed type has no declared dir.
- 23 new tests in
tests/OutputConfigTest.php.
[2.7.1] — @partial (PATCH/UPDATE)
@partialannotation — marks an UPDATE query as a partial update. Parameters insideCOALESCE(:param, column)in the SET clause become optional (?type $param = null). Passingnullleaves the column unchanged at the database level viaCOALESCE(NULL, column)— no PHP conditionals needed.
-- @name PatchUser -- @partial -- @returns :exec UPDATE users SET email = COALESCE(:email, email), name = COALESCE(:name, name), active = COALESCE(:active, active) WHERE id = :id;
// Generated: required WHERE params first, optional SET params last public function patchUser(int $id, ?string $email = null, ?string $name = null, ?int $active = null): void // Update only email $query->patchUser(id: 1, email: 'new@example.com'); // Update only active $query->patchUser(id: 1, active: 0);
- Param ordering is automatic — WHERE params (required) always come first; SET params (optional) go last, regardless of order in the SQL.
- Can be combined with
@optionalon the same query for optional WHERE filters. - Only valid on
:execUPDATE queries. Detects COALESCE params at compile time — no runtime overhead. - 23 new tests in
tests/PartialTest.php.
[2.7.0] — @searchable dynamic filters
@searchableannotation — adds a typedCriteriaparameter to:manyand:many-paginatedmethods. Enables dynamicWHEREconditions andORDER BYat runtime, without writing separate queries.- Generated
{Group}Criteriaclass — extendsSqlcPhp\Criteria\Criteria. Contains typed per-column methods inferred from the result schema:whereActiveEq(int),whereEmailLike(string),whereIdIn(int ...$values),whereCreatedAtBetween(DateTimeImmutable, DateTimeImmutable),orderByCreatedAt('DESC'), etc. @searchable+@counted— the companion count method also accepts the same Criteria, ensuring counts match the filtered result set.@searchable+:many-paginated— two-branch generation preserved ($limit === null→ all rows).- SQL injection safe — column names in ORDER BY are validated against an
ALLOWED_COLUMNSwhitelist generated at compile time. IN/NOT_IN values use per-element placeholders. - Static WHERE compatibility — if the base SQL already has a
WHEREclause, the Criteria appendsANDconditions. Without a WHERE, it addsWHERE. - Static ORDER BY compatibility — if the base SQL has an ORDER BY, the Criteria replaces it when the caller provides one; falls back to the static order otherwise.
- Immutable Criteria —
add()andorderBy()return new instances; the original is never mutated. SqlcPhp\Criteria\namespace — three new runtime classes:Criteria,Filter,FilterOperator.- 71 new tests in
tests/SearchableTest.php.
-- @name ListBillingConfig -- @class BillingConfig -- @searchable -- @counted -- @returns :many-paginated SELECT id, active, country_id, end_num FROM billing_config;
$results = $query->listBillingConfig( criteria: (new BillingConfigCriteria()) ->whereActiveEq(1) ->whereCountryIdIn(164, 165) ->orderByEndNum('DESC'), limit: 20, offset: 0, ); $total = $query->listBillingConfigCount( criteria: (new BillingConfigCriteria())->whereActiveEq(1) );
[2.6.2] — symfony/yaml migration
- YAML parsing migrated to
symfony/yaml— the hand-written subset-YAML parser (parseYaml,parseList,parseNestedMap, etc.) has been replaced withsymfony/yaml, the standard PHP YAML library. This eliminates a persistent source of subtle parsing bugs — at least 4 bugs in recent versions were caused by edge cases in the custom parser. symfony/yamladded as arequiredependency incomposer.json— users installing via Composer get the real implementation automatically.src/Config/YamlParser.php— the old parsing logic is preserved as a standalone fallback class, used via a thin shim (vendor/symfony/yaml/Yaml.php) in environments wheresymfony/yamlis not yet installed. This ensures zero breaking changes for existing installs during the transition.- No behavior changes — the same
sqlc.yamlconfigs that worked before continue to work.
[2.6.0] — --generate-schema
--generate-schemaCLI flag — connects to a live database and generates theschema.sqlfile automatically. Eliminates the need to write or maintainCREATE TABLEstatements by hand.
# Generate schema from live DB and write to schema.sql (as declared in sqlc.yaml) php vendor/bin/sqlc-php --generate-schema sqlc.yaml # Write to a custom path php vendor/bin/sqlc-php --generate-schema --schema-output=db/schema.sql sqlc.yaml
database:config block — new global and per-target option withdsn,username,password,exclude_tables, andinclude_tables.
database: dsn: "mysql:host=localhost;dbname=myapp;charset=utf8mb4" username: "${DB_USER}" # ${ENV_VAR} expanded at runtime password: "${DB_PASS}" exclude_tables: - migrations - failed_jobs - sessions
${ENV_VAR}expansion — credentials can be stored as environment variable references so the YAML file is safe to commit. Unknown variables are left unexpanded so the error is visible.- Engine detection from DSN — the engine is inferred from the DSN prefix (
mysql:→ mysql,pgsql:→ postgres). - MySQL support only (v2.6.0) — uses
SHOW TABLES+SHOW CREATE TABLE. PostgreSQL support comes with the Postgres engine in a future version. AUTO_INCREMENT=Nstripped from generated DDL — prevents spurious git diffs on each re-generation.- Generated schema header includes database name, timestamp, table count, and a
Do not edit manuallynote. SchemaExtractorInterface+MySQLSchemaExtractor+SchemaExtractorFactory— newsrc/SchemaExtractor/layer.- YAML parser extended —
parseListnow handles nested maps within list items (e.g.database:inside atargets:entry), enabling per-target database config. - 26 new tests in
tests/GenerateSchemaTest.php.
[2.5.3] — @class annotation and class_suffix
@class ClassName— new canonical annotation, replaces@group. Functionally identical: sets the PHP class name for the generated Query/Repository/… class. Using@classemits no warnings.@groupis deprecated — still works for backward compatibility, but emits a warning to stderr:@group is deprecated, use @class instead. No behavior change.class_suffixconfig option — global or per-target option that controls the suffix appended to generated class names. Default:Query. Examples:Repository→UserRepository,Service→UserService.
# Global — all targets use this suffix class_suffix: Repository targets: - namespace: "App\\Database" out: generated queries: queries.sql # class_suffix: Service ← override per-target
-- @name GetUser -- @class User ← new canonical annotation (replaces @group) -- @returns :one SELECT * FROM users WHERE id = :id;
Generated: UserRepository.php with class UserRepository (and UserRepositoryInterface if generate_interfaces: true).
@classand@groupboth work together — if both are declared, the first one wins (standard behavior).- 22 new tests in
tests/ClassAnnotationTest.php.
[2.5.2] — Optional pagination limit
:many-paginatedsignature changed —$limitis now?int $limit = nullinstead ofint $limit = 20. Calling->listUsers()without arguments now returns all rows instead of the first 20. Pass a non-null$limitto activate pagination.- Two code paths generated — when
$limit === null, the method prepares the SQL withoutLIMIT/OFFSETand skips those bindings. When$limit !== null, it prepares the SQL withLIMIT :limit OFFSET :offsetand binds all three values. Both paths bind the same user-defined WHERE params. prepared_statement_cache: true— each path uses a distinct cache key (__FUNCTION__ . '_all'and__FUNCTION__ . '_page') to avoid caching the wrong statement.@countedunaffected — the companion{name}Count()method never had$limit/$offsetand continues to work correctly.- Interface updated — the
*Interfacemethod signature reflects?int $limit = null. - 19 new tests in
tests/OptionalPaginationTest.php.
[2.5.0] — @counted pagination
@countedannotation — adds an automatic{name}Count(): intcompanion method to any:many-paginatedquery. The count method wraps the original SQL inSELECT COUNT(*) FROM (...) AS _count_subquery, correctly handlingWHERE,GROUP BY,HAVING,JOIN, and@optionalparams.- No
$limit/$offsetin count signature — the companion method accepts all user-defined WHERE params but not the pagination params, since they don't affect the total count. @optional+@countedworks correctly — the_chktokens are bound in the count method as expected.- Interface includes count method — when
generate_interfaces: true, the*Interfacefile declares both the main paginated method and the count method. prepared_statement_cache: true+@counted— the count method also uses$this->stmts[__FUNCTION__] ??=caching.$limit/$offsetfiltered frombuildParamList— for:many-paginatedqueries, the auto-injected pagination params no longer appear in user-facing method signatures or docblocks. They were always bound separately, but were incorrectly included in$query->paramsafterParamResolverprocessed the rewritten SQL.- 25 new tests in
tests/CountedTest.php.
[2.4.0] — Watch mode
--watchflag — starts a file-system polling loop that regenerates automatically when any watched file changes. Watched files includesqlc.yaml, allschema:files, and allqueries:files from every target. On config change the watch list is updated automatically to reflect new files.--interval=N— set the polling interval in milliseconds (default: 500ms, minimum: 100ms). Example:--watch --interval=250.Watcherclass —src/Watcher.phptracks files byfilemtimeand returns changed paths on eachpoll()call. The watch list can be replaced at runtime viasetAll()to adapt to config changes.runGeneration()function — the CLI generation logic was refactored into a top-levelrunGeneration(configPath, verifyMode, dryRun, diffMode, silent)function, enabling both single-run and watch-loop invocation without code duplication.--watchcannot be combined with--verify,--dry-run, or--diff— attempting this prints an error and exits 1.- 16 new tests in
tests/WatcherTest.php.
[2.3.0]
:batchreturn type — executes the same query N times inside a single PDO transaction with automatic rollback. The method acceptsarray $rows, binds each row's values, and returnsint(affected row count). Throws\Throwableon failure and rolls back.:transactionreturn type — groups multiple:execcalls from the same Query class into a single transaction method. Declare with@calls method1,method2to specify which methods to call in sequence. Requires@groupannotation.@callsannotation — companion to:transaction. Lists the method names to call in the transaction, comma-separated.- Prepared statement caching — opt-in per target via
prepared_statement_cache: trueinsqlc.yaml. Generates aprivate array $stmts = []property and uses$this->stmts[__FUNCTION__] ??= $this->pdo->prepare(...)for all non-IN-list methods. INSERT INTOgroup inference —extractFromTablenow recognisesINSERT INTO tablefor:batchqueries, enabling automatic@groupinference from the INSERT target table.NULLliteral →mixed—NULL AS aliasin SELECT is now correctly handled inExpressionTypeResolverinstead of falling through to the default.- Subquery in FROM emits warning —
(SELECT ...)in a SELECT expression now writes a warning to stderr instead of silently returningmixed. - Virtual table JOIN alias resolution — columns from virtual tables accessed via aliases (
vs.order_countwherevs→user_summary) now resolve to the correct type. TheQueryAnalyzerreceives theSchemaCatalogto look up virtual table columns via alias. - 26 new tests in
tests/NewFeaturesV23Test.php.
[2.2.0]
@dto ClassName— overrides the auto-generated{QueryName}RowDTO class name. Multiple queries can share the same@dtoname if their column shapes match. A warning is emitted when two queries with different shapes declare the same@dtoname.@column originalName alias— renames a result column in the generated DTO without addingASto the SQL. Works onSELECT *queries (forces a custom DTO), JOIN queries, and aggregate queries. Multiple@columnannotations can be stacked.Version::VERSIONand--versionflag —src/Version.phpis the single source of truth for the project version.php vendor/bin/sqlc-php --version(or-v) prints the version and exits.- 21 new tests in
tests/NewFeaturesV22Test.php.
[2.1.1] — Bug fixes and DateTimeImmutable
Bug fixes
MAX(alias.col)/MIN(alias.col)/SUM(alias.col)resolved to?string—ExpressionTypeResolver.resolveInnerType()received the inner expression already uppercased (e.g.M.VOUCHER_NUMBER) but the table alias map had lowercase keys (m). The lookup silently fell through to thestringfallback. Fix:strtolower($inner)at the start ofresolveInnerType().
DateTimeImmutable mapping
DATE,DATETIME,TIMESTAMPnow map to\DateTimeImmutable— previously all three mapped tostring, requiring atype_overrideto get proper date objects.TIMEstaysstring— no standard PHP type for time intervals.TypeMapperInterface::fromRowCast()— new method that generates the correctfromRow()cast expression for any PHP type. Handles\DateTimeImmutable, backed enums (::from()/::tryFrom()), array/JSON, and all scalars.ModelGenerator,ResultDtoGenerator,EmbedGenerator— all three generators now delegatefromRow()cast generation to the mapper instead of maintaining their own hardcodedbuildCast(). Adding support for any new PHP type in a future engine (e.g. PostgreSQLuuid → Uuid) only requires updating the mapper.- Users who need
stringfor date columns can usetype_overrides:type_overrides: - db_type: "DATE" php_type: "string"
- 14 new tests in
tests/TypeMapper/MySQLTypeMapperTest.php.
[2.1.0] — virtual_tables and includes
virtual_tables:— declare tables that exist in the database but not in the schema files (views, materialized views, external tables). Columns default toNOT NULL; marknullable: trueonly when needed. Virtual tables participate in column type resolution and@groupinference like regular tables, but noModelclass is generated for them.includes:— split the config into multiple YAML fragments. Each include file can containvirtual_tables:,type_overrides:, andtargets:sections, all of which are accumulated (appended) in order before the main file's values. Scalar fields (engine,language) in include files are silently ignored — the main file always controls them.- Inline map syntax in YAML — column definitions can now use
{ name: id, type: INT }inline syntax in addition to the full multi-line block form. - YAML parser fix —
parseListnow correctly handles multiple map entries that each contain a nested sub-list (e.g. multiplevirtual_tablesentries each with their owncolumns:list). - 24 new tests in
tests/VirtualTableTest.php.
[2.0.0] — Unified configuration
targets:is now required — thephp:block has been removed. All configuration lives undertargets:. This eliminates the dual configuration paths and makes the schema explicit.generate_interfacesdefaults totrue— interfaces are now generated by default. Setgenerate_interfaces: falseon a target only when not needed.engineandlanguageare global fields — no longer nested insidephp:. Both can be overridden per target.version: "2"— configs should declareversion: "2". Omitting it defaults to"2".schema:andtargets:are both required — omitting either throws a clearRuntimeExceptionat startup.targets:supports nestedqueries:lists — the YAML parser was extended to handle two-level nesting (list of maps, each with its own sub-list), enabling per-target query file lists.- No behavior change — the generation pipeline is identical. Only the config surface changed.
Migration from v1:
# v1 (removed) version: "1" schema: schema.sql queries: queries.sql php: namespace: "App\\Database" out: generated engine: mysql generate_interfaces: true language: spanish type_overrides: - db_type: "TIMESTAMP" php_type: "\\DateTimeImmutable" # v2 (current) version: "2" schema: schema.sql engine: mysql language: spanish type_overrides: - db_type: "TIMESTAMP" php_type: "\\DateTimeImmutable" targets: - namespace: "App\\Database" out: generated queries: queries.sql
[1.6.0] — PostgreSQL groundwork
TypeMapperInterface— new interface (src/TypeMapper/TypeMapperInterface.php) that all type mappers must implement. DefinestoPhpType()andtoPdoParam()with their full signatures.TypeMapperFactory— new factory (src/TypeMapper/TypeMapperFactory.php) that resolves the correct mapper implementation based onengineinsqlc.yaml. Currently supportsmysql;postgres/postgresql/pgsqlthrow a clear error pointing to v1.7.0.MySQLTypeMapper implements TypeMapperInterface—MySQLTypeMappernow explicitly implements the interface.toPdoParam()signature updated to accept optional$tableNameand$columnNamefor interface compatibility.- Dependency injection refactor — all consumers (
ParamResolver,ColumnResolver,ExpressionTypeResolver,ModelGenerator,QueryGenerator) now depend onTypeMapperInterfaceinstead of the concreteMySQLTypeMapper. Zero behaviour change. - CLI uses
TypeMapperFactory—bin/sqlc-phpnow callsTypeMapperFactory::create($config->engine, ...)instead ofnew MySQLTypeMapper(...)directly. - 16 new tests in
tests/TypeMapper/TypeMapperFactoryTest.phpcovering the interface contract, factory engine resolution, error messages, and unsupported engines.
[1.5.3]
IN()clause support — parameters insideIN (:param)andNOT IN (:param)clauses are now automatically detected and handled. The resolver infers the element type from the column (e.g.id IN (:ids)→int[] $ids). The generated method acceptsarray $ids, validates it is non-empty, and expands placeholders at runtime usingstr_replace+execute([...$ids])— no manual SQL building required.- Multiple
IN()params — a single query can have any number of IN-list params, each independently expanded. - Mixed IN + regular params — IN-list and named params coexist in the same query. Regular params use
bindValue(); IN-list values are spread intoexecute(). - Element type inference — the docblock annotation uses
int[],string[], etc. inferred from the column definition. NOT INsupported —NOT IN (:param)is detected identically toIN (:param).- 28 new tests in
tests/InListParamTest.phpcovering detection, type inference, signature generation, placeholder expansion, multiple IN params, mixed queries, and all return types.
[1.5.2] — Bug fixes
Critical
:many-paginated+ existingLIMITclause — the analyzer now throws aRuntimeExceptionif a:many-paginatedquery already contains aLIMITkeyword, preventing silent SQL duplication.:many-paginatedparam name collision — throwsRuntimeExceptionwhen the query has a user-defined param named:limitor:offset, which would conflict with the auto-injected pagination params.- Backtick-quoted table/column names ignored —
SchemaParsernow correctly parses tables and columns wrapped in backtick identifiers (`user_sessions`,`session_id`). Previously these tables were silently skipped. - Overlapping
@embedprefixes —ResultDtoGeneratornow sorts embed definitions by prefix length descending before assigning columns, so longer (more specific) prefixes likerole_type_win over shorter ones likerole_. BETWEENwith@optional—SqlRewriternow guards againstBETWEEN :param AND :param2as an unsafe construct. Previously the condition was silently left unrewritten while the method signature had= null, causing runtime SQL errors.@embed+@nillableinconsistency — when all columns of an@embedgroup are marked@nillable, the parent DTO property is now?ClassNameandfromRowuses a conditionalisset($row['col']) ? Cls::fromRow($row) : nullcast, preventing invalid object hydration.
Medium
DEFAULTwith apostrophe —SchemaParsernow correctly parsesDEFAULT 'it''s ok', handling escaped single quotes inside DEFAULT string values. Previously the parser truncated at the backslash, causing subsequent columns to potentially be misread.PRIMARY KEYimpliesNOT NULL— columns declared asPRIMARY KEYorAUTO_INCREMENTare now markednullable = falseregardless of whetherNOT NULLis written explicitly. Previouslyid INT PRIMARY KEYproduced?int $idinstead ofint $id.- First
@groupwins — if a query has multiple@groupannotations, the first one now takes precedence. Previously the last one won. @optionalbeforeWHEREthrows — the analyzer now validates that@optionalparams appear only inWHEREclauses. If a param appears inSELECTor another non-WHERE position, aRuntimeExceptionis thrown at generation time instead of silently generating invalid SQL at runtime.isActive/hasRoleprefix resolution —ParamResolvernow strips common boolean prefixes (is_,has_,can_,was_,will_) when looking up column names, so:isActivecorrectly resolves to theactivecolumn's type instead of falling back tomixed.@embedwithout prefix throws —QueryParsernow throwsRuntimeExceptionwhen@embed ClassNameis declared without a prefix argument, instead of silently generating an embed that matches no columns.
Tests — 28 new regression tests in tests/BugFixTest.php.
[1.5.1]
doctrine/inflectorintegration — class name inference now uses doctrine/inflector for accurate singularisation and PascalCase conversion. Fixes incorrect singularisation of irregular English plurals (analyses→Analysis,matrices→Matrix,aliases→Alias) that the previous built-in implementation got wrong.languageconfig option — new optional global field insqlc.yaml. Acceptsenglish(default),spanish,french,portuguese,norwegian-bokmal,turkish. Can be overridden per target. Enables accurate class name inference for non-English table names without requiring@groupannotations on every query.InflectorService— new class (src/Inflector/InflectorService.php) wrapping doctrine/inflector with a built-in English fallback for environments where the package is not installed. Transparent — no exceptions thrown when the dependency is absent.composer.json— added"doctrine/inflector": "^2.0"torequire.- 26 new tests in
tests/InflectorServiceTest.phpcovering all six supported languages, the fallback behaviour, Config parsing, QueryParser group inference, and EnumGenerator class naming.
[1.5.0]
@embed— nested objects for JOIN results —-- @embed ClassName prefix_groups all result columns whose alias starts withprefix_into a nestedreadonlyvalue object instead of flattening them into the parent DTO. Multiple@embedannotations can be stacked on one query, each producing a separate file. The embedded class implementsfromRow(array $row): selfusing the original prefixed column names from the flat PDO row, so no extra queries or joins are needed at runtime.EmbedDefinition— new value object (src/Parser/EmbedDefinition.php) carryingclassName,prefix, and helperspropertyName(),matches(),stripPrefix().EmbedGenerator— new generator (src/Generator/EmbedGenerator.php) that produces the standalonereadonly classfiles for each@embedgroup.ResultDtoGeneratorupdated to partition result columns into embed groups and flat remainder; thegenerate()return shape gains anembedskey listing generated value-object files.QueryAnalyzerupdated so that any@embedon a query forcesreturnsModelDirectly = false, guaranteeing a custom DTO is always generated.QueryParserupdated to parse@embed ClassName prefix_annotations; prefix is normalised (trailing underscore always present).- 26 new tests in
tests/EmbedTest.phpcoveringEmbedDefinition,QueryParser,QueryAnalyzer,EmbedGenerator,ResultDtoGenerator, andQueryGenerator.
[1.4.0]
:many-paginatedreturn type — auto-injectsLIMIT :limit OFFSET :offsetinto the SQL at analysis time and appendsint $limit = 20, int $offset = 0to the generated method signature. User-defined params always appear first;$limitand$offsetare last. Works with@optionalparams on the same query.@nillableon direct model queries — previously@nillableonly worked on multi-table JOIN queries. Now, when@nillableis used on a single-tableSELECT *query (which would normally reuse the table model), a dedicated*RowDTO is generated instead, allowing nullability overrides without mutating the base model class.- Multiple output targets —
targets:block insqlc.yamlallows generating multiple namespaces and output directories from the same schema in a single CLI run. Each target has its ownnamespace,out,queries,generate_interfaces, and optionaltype_overridesthat merge on top of the root-level overrides. --dry-runflag — prints the full content of every file that would be generated to stdout, without writing anything to disk.--diffflag — shows a colored unified diff between current files and what would be generated. Exits0when nothing would change,1when there are differences. Writes nothing.- Parser fix —
@returnsregex now accepts hyphens, enabling:many-paginatedto be parsed correctly (previously only\wcharacters were matched). - YAML
parseScalarfix — double-quoted strings now correctly unescape\\→\,\"→",\n→ newline. This fixes namespace values like"App\\Database"being stored asApp\\Databaseinstead ofApp\Database. - 33 new tests in
tests/NewFeaturesV14Test.phpcovering all five features end-to-end.
[1.3.0]
- Multiple schema files —
schemainsqlc.yamlnow accepts a scalar string (legacy) or a YAML list, mirroring the existingquerieslist support. All files are parsed and merged into a single catalog before analysis. Theconfig->schemasproperty always returnsstring[]. - Nullable override in
type_overrides— entries now accept an optionalnullable: true|falsefield that forces the nullability of the generated property regardless of the schema column definition. Can be used alone (withoutphp_type) to only change nullability while keeping the default type mapping. @deprecatedannotation — adding-- @deprecated reasonto a query emits a@deprecatedPHPDoc tag on the generated method. The reason message is optional. The tag appears before@paramlines following PHPDoc convention.@nillableannotation — adding-- @nillable columnAliasforces a specific result column to be?typein the generated DTO or return type, regardless of the schema. Useful for LEFT JOIN queries where a column from the joined table may beNULLat runtime even though it isNOT NULLin the schema. Multiple@nillableannotations can be stacked on the same query.- 33 new tests in
tests/Config/NewFeaturesTest.phpcovering all four features end-to-end.
[1.2.0]
- MySQL ENUM → PHP backed enum —
ENUM('a','b','c')columns generate a PHP 8.1 backed enum file (e.g.OrderStatus.php). The DTO uses the enum as the property type.fromRowuses::from()forNOT NULLcolumns and::tryFrom()for nullable ones. Hyphenated values are converted to PascalCase case names (in-progress→case InProgress). - JSON column → typed array —
JSONcolumns now map toarray(previouslystring).fromRowautomatically callsjson_decode(..., true)with a?? []fallback forNOT NULLcolumns. - Generate PHP interfaces — enabling
generate_interfaces: trueinsqlc.yamlgenerates a*Interfacefile alongside each Query class (e.g.UserQueryInterface). The Query class declaresimplements UserQueryInterface. Useful for Laravel DI, mocking in tests, and depending on abstractions rather than concrete PDO classes. --verifyflag for CI —php vendor/bin/sqlc-php --verify sqlc.yamlexits0when all generated files are up to date,1otherwise. Reports missing and modified files with a regeneration hint. Writes nothing to disk.- 49 new tests across
EnumGeneratorTest,JsonTypeTest,InterfaceGeneratorTest, andVerifyFlagTest.
[1.1.0]
- Optional query parameters — parameters can be marked with
@optional. The SQL condition is rewritten at generation time so that passingnullskips the filter entirely, without any PHP-side conditionals. SqlRewriter— rewritescol OP :paraminto(:param IS NULL OR col OP :param)for every occurrence of the parameter. Supported operators:=,<>,!=,>,<,>=,<=,LIKE,ILIKE.- Unsafe construct guard — queries with
JOIN,HAVING, or subqueries (IN / EXISTS) produce a fatal error at generation time when@optionalis used, preventing silently incorrect SQL. - Parameter validation —
@optionalnames are validated against the SQL at parse time; typos produce a fatal error with the list of known params. - Method signature — required params first, optional params last with
= nulland forced nullable type. - 34 new tests across
SqlRewriterTestandOptionalParamTest.
[1.0.0]
- Multiple query files —
queriesinsqlc.yamlaccepts 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 with auto-generated aliases. :optreturn type —:onethrowsRuntimeExceptionwhen no row is found;:optreturnsnull.- Type overrides —
type_overridesinsqlc.yamlremaps 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.