Download the PHP package phpibe/sqlc-php without Composer
On this page you can find all versions of the php package phpibe/sqlc-php. It is possible to download/install these versions without Composer. Possible dependencies are resolved automatically.
Informations about the package sqlc-php
sqlc-php | https://phpibe.github.io/sqlc-php
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
- 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
Then run the CLI from your project root:
Configuration — sqlc.yaml
Minimal single-target config
generate_interfaces defaults to true — interfaces are generated unless explicitly set to false:
Multiple output targets
targets accepts any number of entries — each produces a separate generation pass using the same parsed schema:
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:
Per-target queries
Each target has its own queries list. queries accepts a scalar or a list:
The CLI prints a per-file count alongside the total:
Inflection language
sqlc-php uses doctrine/inflector to singularise table names when inferring class names. Set globally or per-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.
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.
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.
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:
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:
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
Generated method:
SELECT * with WHERE — :one throws, :opt returns null
Generated methods:
SELECT specific columns
When columns come from a single table, the return type is still the table model:
JOIN — generates a result DTO
When columns come from multiple tables, a dedicated *Row DTO is generated:
Generated files:
GetUserWithRoleRow.php— readonly DTO withid,email,role_name,role_description- Method in
UserQuery.php:
Aggregate and expression columns
sqlc-php infers types from SQL functions. Aliases are generated automatically when none is provided (mirroring sqlc/Go behaviour):
Generated DTO:
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.
Generated method:
The SQL stored in the class becomes:
Any user-defined parameters appear first in the signature; $limit and $offset are always last:
IN() clauses — array parameters
Parameters inside IN() clauses are automatically detected and handled with dynamic placeholder expansion at runtime. No manual SQL building required.
Generated method:
The element type in the docblock (int[]) is inferred from the column type, just like any other parameter.
Mixed IN and regular parameters
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
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):
: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.
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.
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.
With caching enabled, the generated class includes private array $stmts = [] and every method uses:
UPDATE / DELETE — :exec
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.
Generated enum:
Generated DTO property and cast:
Nullable ENUM columns use ::tryFrom():
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:
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.
Generated method:
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:
Generated DTO (multi-table → custom DTO):
*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:
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.
Generated files:
Role.php — standalone readonly value object with stripped property names:
GetUserWithRoleRow.php — parent DTO with the nested Role object as a property:
Usage:
Multiple @embed groups on one query
Generates Role.php, Address.php, and GetUserFullRow.php with:
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.
sqlc-php rewrites each optional condition before emitting any PHP:
Generated method:
Calling the method:
Mixing required and optional parameters
Required parameters always appear first in the signature; optional parameters follow with = null.
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:
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
Model class example (User.php)
Query class example (UserQuery.php)
When generate_interfaces: true, the class declares implements UserQueryInterface:
Interface example (UserQueryInterface.php)
Usage in your application
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
2. Register the binding in a Service Provider
If your application uses multiple database connections, pass the connection name explicitly:
3. Inject the repository into a controller
4. Inject into a service or job
5. Testing with the interface
Because the repository depends on UserQueryInterface, you can swap in a mock without touching the database:
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.
--dry-run — preview without writing
Prints the full content of every file that would be generated to stdout. Writes nothing to disk.
--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.
Running the tests
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
Changelog
[2.9.3] — scoped_dtos path includes @class group
Iterates the scoped_dtos: true feature. The subdirectory now includes the @class group as a parent:
This matches the exact structure from the feature request:
Generates:
Also enables two groups with the same method name to coexist:
[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:
scoped_dtos: true: each query's DTOs and embeds get a dedicated subdirectory named after the method. Collisions become structurally impossible:
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.
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.
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:
@returning — INSERT that fetches and returns the created row:
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:
Fix — Option B: toString() + toDebugBindings():
- 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.
[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. - 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. 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. - 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. - 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.
[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. -
database:config block — new global and per-target option withdsn,username,password,exclude_tables, andinclude_tables. ${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.
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: - 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:
[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.
All versions of sqlc-php with dependencies
doctrine/inflector Version ^2.0
symfony/yaml Version ^6.0 || ^7.0 || ^8.0
psr/log Version ^1.0 || ^2.0 || ^3.0