A PHP code generator inspired by sqlc. Write annotated SQL, get fully-typed PHP 8.4 classes using PDO — no ORM, no query builder, no magic.
How it works
sqlc-php reads your SQL schema and annotated query files, resolves column types, and generates a Model class (readonly, hydrated from PDO rows), a Query class (one method per query), and an optional Interface for dependency injection.
Requirements
| Requirement | Version |
|---|---|
| PHP | ≥ 8.3 |
| PDO extension | Enabled (default in most environments) |
| symfony/yaml | ^6.0 || ^7.0 || ^8.0 |
| doctrine/inflector | ^2.0 (optional — for pluralization) |
Installation
composer require phpibe/sqlc-php
Then create a sqlc.yaml config, annotate your queries, and run:
php vendor/bin/sqlc-php sqlc.yaml
Configuration — sqlc.yaml
All configuration lives in a single YAML file. Here is the full reference with every available option:
version: "2"
# ── Schema files ────────────────────────────────────────────────────────────
schema:
- database/schema/users.sql # one or more SQL files with CREATE TABLE
- database/schema/orders.sql
# ── Global defaults (can be overridden per target) ──────────────────────────
engine: mysql # database engine (mysql supported; postgres planned)
language: english # english | spanish | french | portuguese | etc.
class_suffix: Query # suffix for generated class names (default: Query)
# e.g. Repository → UserRepository, Service → UserService
# ── Global type overrides ───────────────────────────────────────────────────
type_overrides:
- db_type: "TINYINT"
php_type: "bool"
# ── Database connection for --generate-schema ───────────────────────────────
database:
dsn: "mysql:host=localhost;dbname=myapp;charset=utf8mb4"
username: "${DB_USER}" # ${ENV_VAR} expanded from environment at runtime
password: "${DB_PASS}"
exclude_tables: # skip these tables when extracting schema
- migrations
- failed_jobs
# include_tables: # whitelist — only extract these tables
# - users
# - orders
# ── Virtual tables (views, CTEs, external tables) ───────────────────────────
virtual_tables:
- name: user_summary
columns:
- { name: id, type: INT }
- { name: email, type: VARCHAR }
- { name: total, type: INT }
# ── Includes (split config across files) ────────────────────────────────────
includes:
- config/views.yaml
# ── Output targets ──────────────────────────────────────────────────────────
targets:
- namespace: "App\\Database"
out: generated # output directory
queries:
- database/queries/users.sql
- database/queries/orders.sql
generate_interfaces: true # generate *Interface.php (default: true)
prepared_statement_cache: false # cache PDOStatements in $this->stmts (default: false)
class_suffix: Query # per-target override of global class_suffix
engine: mysql # per-target engine override
language: english # per-target language override
type_overrides: # per-target overrides (merged with global)
- db_type: "DECIMAL"
php_type: "float"
database: # per-target DB override for --generate-schema
dsn: "mysql:host=db2;dbname=other"
Configuration options reference
| Key | Level | Default | Description |
|---|---|---|---|
schema | global | — | SQL files with CREATE TABLE statements |
engine | global / target | mysql | Database engine for type mapping |
language | global / target | english | Language for pluralization (model class names) |
class_suffix | global / target | Query | Suffix appended to generated class names |
type_overrides | global / target | [] | Custom SQL → PHP type mappings |
database | global / target | null | Connection config for --generate-schema |
virtual_tables | global | [] | Tables not in schema files (views, CTEs) |
includes | global | [] | Split config across multiple YAML files |
targets[].namespace | target | — | PHP namespace base for generated files |
targets[].out | target | — | Output directory (string) or per-type map — see below |
targets[].queries | target | — | SQL files with annotated queries |
targets[].generate_interfaces | target | true | Generate *Interface.php files |
targets[].prepared_statement_cache | target | false | Cache PDOStatement objects across calls |
out: — string vs map form
By default out: is a string and all generated files share one directory and namespace. When out: is a YAML map, each file type gets its own directory — and its namespace is automatically derived from the last path segment appended to the target's base namespace.
String form — all types in one directory (backward-compatible default)
targets:
- namespace: "App\\Database"
out: generated # UserQuery.php, User.php, GetUserRow.php — all go here
queries: queries.sql
Map form — each type in its own directory
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 # → App\Database\Enums\OrderStatus.php
interfaces: database/Contracts # → App\Database\Contracts\UserRepositoryInterface.php
criterias: database/Criterias # → App\Database\Criterias\UserCriteria.php
| Key | Contains |
|---|---|
queries | Query / Repository classes (UserRepository.php) |
models | Model classes (User.php) — readonly, generated from schema |
dtos | Result DTOs (GetUserWithRoleRow.php) and Embed DTOs from @embed |
enums | Backed enum classes generated from MySQL ENUM columns |
interfaces | Query interfaces (UserRepositoryInterface.php) |
criterias | Criteria classes generated by @searchable queries |
Namespace rule: namespace + '\' + last path segment. The prefix is filesystem-only and does not affect the namespace.
Automatic use statements: when types live in different namespaces, sqlc-php injects the necessary use statements into generated files automatically.
Error policy: if a query generates a file type that has no declared directory in the map, generation stops with a clear error before writing any files.
Partial maps: you only need to declare the types your project actually uses. If no query uses @searchable, you can omit out.criterias.
DDD / Laravel example
targets:
- namespace: "App\\Modules\\Billing"
queries: database/queries/billing.sql
class_suffix: Repository
out:
queries: app/Modules/Billing/Infrastructure # BillingConfigRepository.php
models: app/Modules/Billing/Domain # BillingConfig.php
dtos: app/Modules/Billing/Infrastructure/DTOs # ListBillingConfigRow.php
interfaces: app/Modules/Billing/Domain/Ports # BillingConfigRepositoryInterface.php
enums: app/Modules/Billing/Domain/Enums # OrderStatus.php
criterias: app/Modules/Billing/Infrastructure # BillingConfigCriteria.php
type_overrides
Override the default SQL → PHP type mapping. Applies globally or per-target.
type_overrides:
- db_type: "TINYINT"
php_type: "bool"
- db_type: "DECIMAL"
php_type: "float"
- db_type: "TIMESTAMP"
php_type: "\\DateTimeImmutable"
nullable: true
Precedence: target-level overrides → global overrides → built-in defaults.
SQL → PHP type mapping
| MySQL type | PHP type | fromRow cast |
|---|---|---|
INT, BIGINT, SMALLINT, TINYINT | int | (int) |
FLOAT, DOUBLE, DECIMAL, NUMERIC | float | (float) |
VARCHAR, CHAR, TEXT, LONGTEXT | string | — |
DATE, DATETIME, TIMESTAMP | \DateTimeImmutable | new \DateTimeImmutable() |
JSON | array | json_decode(, true) |
ENUM('a','b') | EnumClass | EnumClass::from() |
NULL columns | ?type | null check before cast |
--generate-schema
Connects to a live database and generates schema.sql automatically. No need to write or maintain CREATE TABLE statements by hand.
# Write to the first schema: path 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=database/schema.sql sqlc.yaml
Workflow
# 1. Extract schema from live DB
php vendor/bin/sqlc-php --generate-schema sqlc.yaml
# 2. Generate PHP classes
php vendor/bin/sqlc-php sqlc.yaml
database: block
database:
dsn: "mysql:host=localhost;dbname=myapp;charset=utf8mb4"
username: "${DB_USER}" # expanded from environment at runtime
password: "${DB_PASS}"
exclude_tables:
- migrations
- failed_jobs
- sessions
# include_tables: # whitelist — only extract these tables
# - users
# - orders
Security — credentials in environment
# sqlc.yaml (safe to commit)
database:
dsn: "mysql:host=${DB_HOST};dbname=${DB_NAME};charset=utf8mb4"
username: "${DB_USER}"
password: "${DB_PASS}"
# .env.local (add to .gitignore)
DB_HOST=localhost
DB_NAME=myapp
DB_USER=root
DB_PASS=secret
source .env.local && php vendor/bin/sqlc-php --generate-schema sqlc.yaml
SHOW TABLES + SHOW CREATE TABLE. AUTO_INCREMENT=N is stripped from the output to avoid noisy git diffs.
Annotations reference
Annotations appear as SQL comments (-- @annotation value) immediately before the query. Every query requires at minimum @name and @returns.
| Annotation | Required | Description |
|---|---|---|
| @name | Yes | PHP method name (camelCase) |
| @returns | Yes | Return type — see Return Types section |
| @class | No | Group name → generated class prefix (e.g. User → UserQuery). Inferred from table name when absent. |
| @group | No | Deprecated — use @class instead. Still works, emits a warning. |
| @optional | No | Make a WHERE param skippable when null |
| @dto | No | Override the auto-generated result DTO class name |
| @column | No | Rename a result column without SQL AS |
| @embed | No | Group prefixed columns into a nested readonly object |
| @json | No | Deserialize a JSON_ARRAYAGG column into a typed ClassName[] array — generates a standalone readonly DTO inferred from the schema table. v2.12.3 |
| @nillable | No | Force a result column to ?type (nullable) |
| @type | No | Force an explicit PHP type on a result column — overrides the inferred type. Essential for UNION queries and constant expressions. v2.9.6 |
| @counted | No | Generate a companion {name}Count(): int method. Valid with :many-paginated and :cursor. For :cursor, counts total rows matching user filters (independent of cursor position). |
| @cursor | No | Declare cursor columns for keyset pagination — required with :cursor. Syntax: col1 [ASC|DESC], col2 [ASC|DESC]. v2.11.2 |
| @searchable | No | Generate a typed Criteria class for dynamic WHERE filters. Valid with :many, :many-paginated, :paginated, and :cursor. For :cursor, orderBy() methods are omitted — ORDER BY is fixed by @cursor. :cursor support v2.11.3 |
| @deprecated | No | Emit @deprecated in the method docblock |
| @param | No | Explicit type for a named parameter |
| @calls | No | Methods to call in sequence (used with :transaction) |
| @partial | No | Mark SET params in an UPDATE as optional — passing null leaves the column unchanged. Only valid on :exec UPDATE queries. |
| @returning | No | After an INSERT, fetch and return the newly created row by its primary key. Only valid on :one INSERT queries without ON DUPLICATE KEY. |
| @cte | — | Declares a named reusable CTE in a .sql file — not a query annotation, but a block header in CTE files. v2.13.0 |
| @use | No | Inject one or more shared CTEs (declared with @cte) into this query as a WITH clause. @use name or @use a, b, c. v2.13.0 |
@name
Sets the PHP method name. The name is used as-is — use camelCase by convention.
-- @name GetUserById
-- @returns :one
SELECT * FROM users WHERE id = :id;
Generates: public function getUserById(int $id): User
@class / @group
@class sets the group name that determines which Query class the method is placed in. Multiple queries with the same @class are combined into one file. When omitted, the group is inferred from the FROM table name.
-- @name ListActive
-- @class User ← all queries with @class User → UserQuery.php
-- @returns :many
SELECT * FROM users WHERE active = 1;
-- @name CountByCountry
-- @class User ← same class → same file
-- @returns :one
SELECT country_id, COUNT(*) AS total FROM users GROUP BY country_id;
Combined with class_suffix: Repository in sqlc.yaml, @class User generates UserRepository.
@group is the deprecated predecessor of @class. It still works but emits a warning to stderr: "@group is deprecated, use @class instead".
@returns
Declares the return type of the generated method. See the Return Types section for full details on each type.
-- @returns :many → array of rows
-- @returns :many-paginated → array of rows with optional LIMIT/OFFSET
-- @returns :paginated → PaginatedResult (items + total + pages + metadata)
-- @returns :cursor → CursorResult (items + hasMore + nextCursor token)
-- @returns :one → single row (throws if not found)
-- @returns :opt → single row or null
-- @returns :exec → void (INSERT / UPDATE / DELETE)
-- @returns :batch → int (same query N times in a transaction)
-- @returns :transaction → void (calls multiple methods in one transaction)
@optional
Makes a WHERE parameter optional. When the caller passes null, the condition is skipped entirely — no PHP-side if required. The SQL is rewritten at compile time using the pattern (:param_chk IS NULL OR col OP :param) which is PDO-safe.
-- @name ListUsers
-- @optional active ← :active becomes ?int $active = null
-- @optional country_id ← multiple @optional allowed
-- @returns :many
SELECT * FROM users
WHERE active = :active
AND country_id = :country_id;
// All users (both filters skipped)
$query->listUsers();
// Only active users
$query->listUsers(active: 1);
// Active users in country 164
$query->listUsers(active: 1, country_id: 164);
@optional is safe to use with JOINs as long as the optional param is in the WHERE clause, not in an ON condition. Params in ON clauses will produce an error at generation time.
Supported operators
= != <> > < >= <= LIKE ILIKE
@dto / @column
@dto overrides the auto-generated result DTO class name. Useful when you want to reuse the same DTO across multiple queries, or when the default name is unwieldy.
-- @name ListBillingConfig
-- @class BillingConfig
-- @dto BillingConfigRow ← generated DTO class: BillingConfigRow.php
-- @returns :many
SELECT
bc.id,
bc.active,
c.name AS country_name
FROM billing_config bc
JOIN countries c ON c.id = bc.country_id;
@column renames a result column in the generated DTO without adding an AS clause to the SQL:
-- @name GetUser
-- @column u_email email ← renames u_email → email in the DTO
-- @returns :one
SELECT id, email AS u_email FROM users WHERE id = :id;
@embed
Groups prefixed columns from a JOIN into a nested readonly object instead of flat properties.
-- @name GetUserWithRole
-- @embed Role role__ ← columns prefixed "role__" → Role object
-- @returns :one
SELECT
u.id,
u.email,
r.name AS role__name,
r.slug AS role__slug
FROM users u
JOIN roles r ON r.id = u.role_id
WHERE u.id = :id;
// Generated: GetUserWithRoleRow.php
readonly class GetUserWithRoleRow {
public function __construct(
public int $id,
public string $email,
public Role $role, // ← nested object
) {}
}
// Generated: Role.php (standalone value object)
readonly class Role {
public function __construct(
public string $name,
public string $slug,
) {}
}
role_, role__, country___ — all are preserved exactly. Use a consistent prefix in both the annotation and the SQL aliases.
@json v2.12.3
Declares that a result column containing JSON output should be deserialized into a typed DTO instead of a plain PHP array. sqlc-php always generates a standalone readonly DTO for the referenced class, inferred from the schema table whose name matches the class name.
Three cardinality variants are supported:
| 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 v2.12.5 | JSON_OBJECT(...) | ClassName |
@json:many — typed array (JSON_ARRAYAGG):
-- @name GetCountryWithCities
-- @class Country
-- @returns :one
-- @json:many cities City ← or simply: @json cities City
SELECT
countries.id,
countries.name,
JSON_ARRAYAGG(
JSON_OBJECT('id', cities.id, 'name', cities.name, 'slug', cities.slug)
) AS cities
FROM countries
INNER JOIN cities ON cities.country_id = countries.id
WHERE countries.id = :id
GROUP BY countries.id;
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) ?? []
),
);
}
}
@json:one — single embedded object (JSON_OBJECT):
-- @name GetUserWithAddress
-- @class User
-- @returns :one
-- @json:one address Address ← single object, not an array
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;
readonly class GetUserWithAddressRow
{
public function __construct(
public int $id,
public string $name,
public Address $address, // ← typed single object
) {}
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 ← JSON_OBJECT → Address $address
-- @json:many orders Order ← JSON_ARRAYAGG → Order[] $orders
- The DTO is always generated — never reused from an existing model class.
- Placement respects
scoped_dtos:— whentrue, the DTO lives inDTOs/{Group}/{Method}/ClassName.php. - When
extensions:is declared, an extension trait scaffold is generated for each JSON DTO (write-once, never overwritten). - Table resolution:
Address→ triesaddress,addresss,addresses. Fails with a clear error if no match. - Bare
@jsondefaults to:many— fully backward-compatible.
@nillable
Forces a result column to be ?type (nullable) in the generated DTO, even if the schema defines it as NOT NULL. Useful for LEFT JOIN columns that may be NULL at runtime.
-- @name GetUserWithOrder
-- @nillable order_id ← force nullable even though orders.id is NOT NULL
-- @nillable order_total
-- @returns :opt
SELECT
u.id,
u.email,
o.id AS order_id,
o.total AS order_total
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.id = :id;
Result: public ?int $orderId and public ?float $orderTotal in the DTO.
@type v2.9.6
Forces an explicit PHP type on a result column, completely overriding whatever the resolver inferred from the schema or expression. This is the primary tool for fixing types in UNION queries, constant string discriminators, and any column the resolver cannot determine.
Syntax
-- @type alias phpType
Primary use case — UNION with constant discriminators and NULL branches
In UNION queries, the resolver analyses only the first SELECT. Constant expressions like 'user' as role resolve to mixed because they have no schema backing. NULL in the second branch is invisible to the resolver. @type corrects both.
-- @name GetProductsByReserveId
-- @class ReserveBilling
-- @dto ProductRow
-- @type role string ← 'direct'/'upgrade' are string constants → resolver gives mixed
-- @type amount ?float ← second UNION branch returns NULL → should be nullable
-- @returns :many
SELECT products.id, products.name, 'direct' AS role, reserve.price AS amount
FROM reserve
INNER JOIN products ON reserve.product_id = products.id
WHERE reserve.id = :reserveId
UNION ALL
SELECT products.id, products.name, 'upgrade' AS role, NULL AS amount
FROM reserve_insured_upgrade
INNER JOIN products ON reserve_insured_upgrade.upgrade_id = products.id
WHERE reserve_insured_upgrade.reserve_id = :reserveId;
// Generated DTO — types from @type take effect
readonly class ProductRow {
public function __construct(
public int $id,
public string $name,
public string $role, // ← @type string
public ?float $amount, // ← @type ?float
) {}
}
Other use cases
-- @type active bool ← TINYINT column used as boolean flag
-- @type subtotal float ← complex expression (price * qty) resolver gives mixed
-- @type label ?string ← column that may be NULL at runtime
-- @type score int ← CAST or aggregate result needing an explicit type
Supported types
| phpType | Nullable variant | Example |
|---|---|---|
int | ?int | -- @type count int |
float | ?float | -- @type total ?float |
string | ?string | -- @type role string |
bool | ?bool | -- @type active bool |
mixed | — | -- @type computed mixed |
array | ?array | -- @type tags ?array |
| Any class | ?ClassName | -- @type dt \DateTimeImmutable |
@nillable and @type target the same column, @type wins — it is applied last and completely replaces the resolved type. Use @type ?float rather than combining @nillable and @type float on the same alias.
@type applies to all return types and query shapes. If any inferred type is wrong — for example a TINYINT being int when you need bool — annotate it with @type.
@counted
Generates a companion {name}Count(): int method alongside a :many-paginated method. The count method wraps the original SQL in SELECT COUNT(*) FROM (...) AS _count_subquery and returns the total number of rows matching the current filters — without any LIMIT or OFFSET applied.
Only valid with :many-paginated.
-- @name ListUsers
-- @counted
-- @returns :many-paginated
SELECT id, email, active FROM users WHERE active = :active;
// Two generated methods
public function listUsers(int $active, ?int $limit = null, int $offset = 0): array
public function listUsersCount(int $active): int
// Usage — the caller decides when to count
$total = $query->listUsersCount(active: 1);
$page = $query->listUsers(active: 1, limit: 20, offset: 0);
$pages = (int) ceil($total / 20);
@counted vs :paginated — choosing the right approach
Both solve pagination, but with different trade-offs. The choice comes down to whether you need control over when the COUNT runs.
:paginated | :many-paginated + @counted | |
|---|---|---|
| DB queries per page load | Always 2 (COUNT + SELECT) | Caller controls — 1 or 2 |
| Return type | PaginatedResult — items, total, pages, hasMore pre-computed | array + separate int |
| Boilerplate | None — one call does everything | Caller must call both methods and compute pages/hasMore |
| COUNT on every page | Yes, always | No — caller decides (e.g. cache it, skip on page 2+) |
| Infinite scroll / cursor | Counts every request unnecessarily | ✅ Count once on page 1, skip on subsequent pages |
| Very large tables | COUNT(*) can be slow on every call | ✅ COUNT can be deferred or cached |
| @searchable compatible | ✅ | ✅ |
When to use :paginated (recommended default)
Use :paginated for the vast majority of paginated lists. It eliminates boilerplate, keeps the page total and navigation metadata in one place, and makes the API surface clean — one method, one call, everything you need.
-- @name ListUsers
-- @returns :paginated ← recommended: one call, full metadata
SELECT * FROM users WHERE active = :active ORDER BY created_at DESC;
$result = $query->listUsers(active: 1, limit: 20, offset: 0);
// $result->items, $result->total, $result->pages, $result->hasMore — all ready
When to use @counted instead
Prefer @counted when COUNT(*) is expensive and you can avoid running it on every request:
// Pattern 1 — count only on first page, reuse for subsequent pages
$total = ($page === 1)
? $query->listUsersCount(active: 1)
: $session->get('users_total'); // cached from page 1
$items = $query->listUsers(active: 1, limit: 20, offset: ($page - 1) * 20);
// Pattern 2 — infinite scroll: never count, just check hasMore manually
$items = $query->listUsers(active: 1, limit: 21, offset: $offset);
$hasMore = count($items) > 20;
$items = array_slice($items, 0, 20); // discard the probe row
// Pattern 3 — export / "load all" button: skip pagination entirely
$all = $query->listUsers(active: 1, limit: null);
:paginated. Switch to @counted only if profiling shows the COUNT(*) is a bottleneck, or if your pagination UX is infinite scroll / load-more (where the total is irrelevant after page 1).
@cursor v2.11.2
Declares the cursor columns for keyset (cursor) pagination. Required when using :cursor as the return type. Accepts one or more column names with optional direction (ASC or DESC, defaulting to ASC).
Syntax
-- @cursor col1 [ASC|DESC], col2 [ASC|DESC], ...
Examples
-- Single column, descending (most recent first)
-- @cursor id DESC
-- Two columns — always include a unique column (id) to break ties
-- @cursor created_at DESC, id DESC
-- Ascending order
-- @cursor name ASC, id ASC
@cursor must exactly match the ORDER BY clause of your SQL, in the same order and direction. sqlc-php does not validate this at generation time — a mismatch causes incorrect results at runtime.
id). If the first cursor column has duplicate values, rows with the same value could be skipped or repeated across pages.
See the :cursor return type and Cursor pagination guide for full usage examples, including @counted and @searchable.
@searchable
Generates a typed {Group}Criteria class and adds it as a parameter to the query method. Enables dynamic WHERE conditions and ORDER BY at runtime — without writing separate queries per filter combination. Only valid with :many and :many-paginated.
-- @name ListBillingConfig
-- @class BillingConfig
-- @searchable
-- @counted
-- @returns :many-paginated
SELECT id, active, country_id, end_num, expiration_date
FROM billing_config;
Generates BillingConfigCriteria.php with one typed method per column per applicable operator:
// Generated: BillingConfigCriteria.php
class BillingConfigCriteria extends \SqlcPhp\Criteria\Criteria
{
// INT columns → Eq, Neq, Gt, Lt, Gte, Lte, In (variadic), NotIn (variadic)
public function whereActiveEq(int $value): static
public function whereActiveIn(int ...$values): static // whereActiveIn(1, 2, 3)
public function whereActiveNotIn(int ...$values): static
public function whereCountryIdIn(int ...$values): static
// Nullable columns → also IsNull, IsNotNull
public function whereEndNumIsNull(): static
public function whereEndNumIsNotNull(): static
// DATE / DATETIME columns → also Between
public function whereExpirationDateBetween(\DateTimeImmutable $from, \DateTimeImmutable $to): static
// VARCHAR / TEXT → also Like, StartsWith, EndsWith
// (none here since no string columns in this query)
// Every column → orderBy method
public function orderByActive(string $direction = 'ASC'): static
public function orderByExpirationDate(string $direction = 'ASC'): static
}
// Generated method signatures
public function listBillingConfig(
?BillingConfigCriteria $criteria = null,
?int $limit = null,
int $offset = 0,
): array
public function listBillingConfigCount(
?BillingConfigCriteria $criteria = null,
): int
// Usage
$criteria = (new BillingConfigCriteria())
->whereActiveEq(1)
->whereCountryIdIn(164, 165)
->orderByExpirationDate('DESC');
$page = $query->listBillingConfig($criteria, limit: 20, offset: 0);
$total = $query->listBillingConfigCount($criteria);
// No criteria — returns all rows
$all = $query->listBillingConfig();
Generated filter methods by type
| SQL type | Generated methods |
|---|---|
INT / TINYINT | Eq, Neq, Gt, Lt, Gte, Lte, In, NotIn — + IsNull/IsNotNull if nullable |
VARCHAR / TEXT | Eq, Neq, Like, StartsWith, EndsWith, In, NotIn — + IsNull/IsNotNull if nullable |
DECIMAL / FLOAT | Eq, Neq, Gt, Lt, Gte, Lte — + IsNull/IsNotNull if nullable |
DATE / DATETIME | Eq, Neq, Gt, Lt, Gte, Lte, Between — + IsNull/IsNotNull if nullable |
TINYINT (bool) | Eq only |
| All types | orderBy{Column}(string $direction = 'ASC') |
ALLOWED_COLUMNS) to prevent SQL injection. IN/NOT_IN values expand to individual PDO placeholders. All filters are combined with AND — OR logic is not supported in v2.7.0.
@deprecated
Emits a @deprecated tag in the generated method docblock. IDEs and static analysis tools will warn callers.
-- @name GetUserLegacy
-- @deprecated Use getUserById instead
-- @returns :opt
SELECT * FROM users WHERE legacy_id = :legacy_id;
/** @deprecated Use getUserById instead */
public function getUserLegacy(string $legacy_id): ?User
@comment v2.14.0
Adds a human-readable description to the generated method docblock and its matching interface method. The description is placed before @param and @return tags, following PSR-5/phpDoc conventions. Multiple @comment lines are supported — each becomes a separate line in the description block.
Basic usage
-- @name GetActiveUser
-- @class Users
-- @returns :opt
-- @comment Returns the active user matching the given ID.
-- @comment Returns null when no user is found or the user is inactive.
SELECT * FROM users WHERE id = :id AND active = 1;
/**
* Returns the active user matching the given ID.
* Returns null when no user is found or the user is inactive.
*
* @param int $id
* @return User|null
*/
public function getActiveUser(int $id): ?User
Example — documenting business rules
-- @name CreateOrder
-- @class Orders
-- @returns :exec
-- @comment Creates a new order in pending state.
-- @comment Automatically assigns created_at via the DB default (NOW()).
-- @comment Throws PDOException on foreign key violation (invalid user_id).
INSERT INTO orders (user_id, total, status) VALUES (:userId, :total, 'pending');
/**
* Creates a new order in pending state.
* Automatically assigns created_at via the DB default (NOW()).
* Throws PDOException on foreign key violation (invalid user_id).
*
* @param int $userId
* @param float $total
* @return void
*/
public function createOrder(int $userId, float $total): void
Example — combined with @deprecated
-- @name GetUserLegacy
-- @class Users
-- @returns :opt
-- @comment Use getUserById for the updated response shape.
-- @comment This method will be removed in v4.0.
-- @deprecated Replaced by getUserById in v3.
SELECT * FROM users WHERE legacy_id = :legacyId;
/**
* Use getUserById for the updated response shape.
* This method will be removed in v4.0.
*
* @deprecated Replaced by getUserById in v3.
* @param string $legacyId
* @return User|null
*/
public function getUserLegacy(string $legacyId): ?User
Example — documenting a @searchable query
-- @name ListUsers
-- @class Users
-- @returns :many
-- @searchable
-- @comment Returns users matching the given filters.
-- @comment Pass null to skip all filters and return every user.
-- @comment Combine with orderByCreatedAt('DESC') for newest-first ordering.
SELECT users.id, users.email, users.name, users.active FROM users;
/**
* Returns users matching the given filters.
* Pass null to skip all filters and return every user.
* Combine with orderByCreatedAt('DESC') for newest-first ordering.
*
* @param ?UsersCriteria $criteria Dynamic filters and ordering. Pass null to skip.
* @return User[]
*/
public function listUsers(?UsersCriteria $criteria = null): array
Interface propagation
The description also appears in the generated interface method, so IDE autocomplete shows it when programming against the interface:
// Generated interface:
interface UsersQueryInterface
{
/**
* Returns the active user matching the given ID.
* Returns null when no user is found or the user is inactive.
*
* @param int $id
* @return User|null
*/
public function getActiveUser(int $id): ?User;
}
- Each
@commentline becomes one line in the docblock description. - A blank
*separator is automatically inserted between the description block and the first@paramor@returntag. @commentcoexists with@deprecated— description appears first, then@deprecated.- Empty
@commentlines (no text after the annotation) are silently ignored. - Works with all return types:
:one,:opt,:many,:many-paginated,:paginated,:exec,:batch,:cursor,:transaction. - Queries without
@commentare completely unaffected — no change in generated output.
:paginated
Executes two queries in one call — a COUNT(*) and a paginated SELECT — and returns a single PaginatedResult object with items and full pagination metadata. Eliminates the need to call listUsers() and listUsersCount() separately.
Cannot be combined with @counted (they are alternatives). Can be combined with @searchable.
-- @name ListUsers
-- @returns :paginated
SELECT * FROM users WHERE active = :active ORDER BY created_at DESC;
// Generated signature — $limit defaults to 10, not nullable
public function listUsers(int $active, int $limit = 10, int $offset = 0): PaginatedResult
// Usage
$result = $query->listUsers(active: 1, limit: 20, offset: 0);
$result->items; // User[] — the current page
$result->total; // 150 — total rows matching the query
$result->limit; // 20
$result->offset; // 0
$result->pages; // 8 — ceil(150 / 20)
$result->hasMore; // true
// Navigation helpers
$result->currentPage(); // 1
$result->isFirstPage(); // true
$result->isLastPage(); // false
$result->nextOffset(); // 20
$result->previousOffset(); // null
With @searchable
-- @name ListUsers
-- @searchable
-- @returns :paginated
SELECT * FROM users;
// Criteria + pagination in one call
$result = $query->listUsers(
criteria: (new UserCriteria())->whereActiveEq(1)->orderByCreatedAt('DESC'),
limit: 20,
offset: 0,
);
// The COUNT also applies the same criteria — total reflects the filtered set
PaginatedResult API
| Property / Method | Type | Description |
|---|---|---|
$r->items | array | Rows for the current page (ModelClass[]) |
$r->total | int | Total rows matching the query (all pages) |
$r->limit | int | Rows per page as requested |
$r->offset | int | Rows skipped as requested |
$r->pages | int | ceil(total / limit) — 0 when total is 0 |
$r->hasMore | bool | True when there are rows after this page |
$r->currentPage() | int | Current page number (1-based) |
$r->isFirstPage() | bool | True when offset is 0 |
$r->isLastPage() | bool | True when there is no next page |
$r->nextOffset() | ?int | Offset for the next page, null if last |
$r->previousOffset() | ?int | Offset for the previous page, null if first |
COUNT(*) and a paginated SELECT in the same PHP call — two database round-trips. This is the right default for most UIs (numbered pages, "showing X of Y" labels). If the COUNT is expensive and you need to avoid running it on every page load — for example in infinite scroll or very large tables — use :many-paginated + @counted instead, which gives you two separate methods and full control over when each runs.
@returning
After executing an INSERT, fetches and returns the newly created row by its primary key (lastInsertId()). Eliminates the manual fetch-after-insert pattern that every controller currently repeats.
Only valid on :one INSERT queries. Cannot be used with ON DUPLICATE KEY UPDATE (unreliable lastInsertId()). The table must have a detectable primary key in the schema.
-- @name CreateUser
-- @returning
-- @returns :one
INSERT INTO users (email, active) VALUES (:email, :active);
// Generated — executes INSERT then SELECT by PK
public function createUser(string $email, int $active): User
// Usage
$user = $query->createUser(email: 'alice@example.com', active: 1);
echo $user->id; // auto-increment PK from lastInsertId()
echo $user->email; // 'alice@example.com'
echo $user->active; // 1
Generated code (simplified)
public function createUser(string $email, int $active): User
{
// Execute the INSERT
$stmt = $this->pdo->prepare('INSERT INTO users (email, active) VALUES (:email, :active)');
$stmt->bindValue(':email', $email, PDO::PARAM_STR);
$stmt->bindValue(':active', $active, PDO::PARAM_INT);
$stmt->execute();
// Fetch the newly created row by its primary key
$__id = (int) $this->pdo->lastInsertId();
$__selectStmt = $this->pdo->prepare('SELECT * FROM users WHERE id = :id');
$__selectStmt->bindValue(':id', $__id, PDO::PARAM_INT);
$__selectStmt->execute();
$__row = $__selectStmt->fetch(PDO::FETCH_ASSOC);
return User::fromRow($__row);
}
Primary key detection
The PK column is detected automatically from the schema (in priority order): inline PRIMARY KEY declaration, AUTO_INCREMENT column, or a column named id. If none match, generation fails with a clear error.
ON DUPLICATE KEY UPDATE is not supported. When an upsert performs an UPDATE instead of an INSERT, lastInsertId() returns 0 or the ID of a previous row. The analyzer will reject any INSERT with ON DUPLICATE KEY and @returning.
@cte v2.13.0
Declares a named, reusable CTE inside a dedicated .sql file. Unlike query annotations (which annotate a single query), @cte is a block header — it names the CTE that follows and has no corresponding query annotation in .sql query files.
A single file can contain any number of @cte blocks, each ending at the next @cte or at EOF:
-- database/ctes/users.sql
-- @cte active_users
-- Users that are active and have the 'client' role.
-- Used by: orders, payments, reports modules.
SELECT
id,
email,
role,
created_at
FROM users
WHERE active = 1
AND role = 'client';
-- @cte active_admins
-- Staff members with admin privileges.
SELECT
id,
email,
role
FROM users
WHERE active = 1
AND role IN ('admin', 'superadmin');
-- @cte new_users_30d
-- Users registered in the last 30 days, regardless of role.
SELECT id, email, created_at
FROM users
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY);
CTE files are pure SQL — no PHP, no YAML, no special syntax beyond the -- @cte name block header. They can be shared across teams, checked into version control, and used as documentation for recurring business concepts.
Register files in sqlc.yaml under ctes::
version: 1
engine: mysql
schema:
- database/schema.sql
# Global CTEs — available to every target in this config
ctes:
- database/ctes/users.sql
- database/ctes/orders.sql
targets:
- namespace: App\Reports
queries:
- database/queries/reports.sql
out: generated/Reports/
ctes:
- database/ctes/billing.sql # per-target — merged with global above
- Each block starts with
-- @cte nameon its own line and ends at the next-- @cteor EOF. - Trailing semicolons are stripped automatically — only the
SELECTbody is used. - Comments and blank lines inside a body are preserved.
- Content before the first
@cteannotation (file-level comments, headers) is silently ignored. - CTE names must be unique across all loaded files — a duplicate triggers a hard error with both source paths.
- CTEs cannot have parameters (
:param) — they are static, reusable fragments.
@use v2.13.0
Injects one or more shared CTEs (declared with @cte) into a query as a WITH clause. sqlc-php resolves and prepends the WITH name AS (...) block before analysis — the Analyzer, Resolver, and all Generators receive the complete SQL transparently.
Syntax
-- Single CTE
-- @use active_users
-- Multiple on one line (comma-separated)
-- @use active_users, recent_orders
-- Multiple on separate lines (equivalent)
-- @use active_users
-- @use recent_orders
Use case 1 — Reusing a business rule across modules
The definition of "active user" lives in one place. Any query in any module can reference it without copy-pasting the WHERE conditions:
-- database/queries/orders.sql
-- @name ListActiveUserOrders
-- @class Orders
-- @returns :many
-- @use active_users
SELECT
orders.id,
orders.total,
orders.status,
orders.created_at
FROM orders
INNER JOIN active_users ON active_users.id = orders.user_id
WHERE orders.status != 'cancelled'
ORDER BY orders.created_at DESC;
-- @name CountActiveUserOrders
-- @class Orders
-- @returns :one
-- @use active_users
SELECT COUNT(*) AS total
FROM orders
INNER JOIN active_users ON active_users.id = orders.user_id;
-- database/queries/payments.sql
-- @name ListActiveUserPayments
-- @class Payments
-- @returns :many
-- @use active_users ← same CTE, different module
SELECT
payments.id,
payments.amount,
payments.created_at
FROM payments
INNER JOIN active_users ON active_users.id = payments.user_id;
Use case 2 — Composing multiple CTEs
A report that needs both active users and recent orders uses both CTEs together. The generated SQL chains them automatically:
-- @name ActiveUserOrderSummary
-- @class Reports
-- @returns :many
-- @use active_users, recent_orders
SELECT
active_users.email,
COUNT(recent_orders.id) AS order_count,
SUM(recent_orders.total) AS total_spent
FROM active_users
LEFT JOIN recent_orders ON recent_orders.user_id = active_users.id
GROUP BY active_users.id, active_users.email
ORDER BY total_spent DESC;
Effective SQL injected before the Analyzer:
WITH
active_users AS (
SELECT id, email, role, created_at
FROM users
WHERE active = 1 AND role = 'client'
),
recent_orders AS (
SELECT id, user_id, total
FROM orders
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
)
SELECT
active_users.email,
COUNT(recent_orders.id) AS order_count,
SUM(recent_orders.total) AS total_spent
FROM active_users
LEFT JOIN recent_orders ON recent_orders.user_id = active_users.id
GROUP BY active_users.id, active_users.email
ORDER BY total_spent DESC;
Use case 3 — Combined with @searchable
@use works transparently with all other annotations. Adding @searchable generates a typed Criteria class for the full query including the injected CTE — column resolution, table.column qualification, and Criteria generation all work normally:
-- @name ListActiveUserOrders
-- @class Orders
-- @returns :many
-- @use active_users
-- @searchable
SELECT
orders.id,
orders.total,
orders.status,
active_users.email AS user_email
FROM orders
INNER JOIN active_users ON active_users.id = orders.user_id;
// Generated usage:
$orders = $query->listActiveUserOrders(
(new OrdersCriteria())
->whereStatusEq('pending')
->whereOrdersTotalGt(100.00)
->orderByCreatedAt('DESC')
);
Use case 4 — Combined with @json:many
Aggregate child rows as a typed JSON array while filtering via a shared CTE:
-- @name GetActiveUserWithOrders
-- @class Users
-- @returns :one
-- @use active_users
-- @json:many orders Order
SELECT
active_users.id,
active_users.email,
JSON_ARRAYAGG(
JSON_OBJECT('id', orders.id, 'total', orders.total, 'status', orders.status)
) AS orders
FROM active_users
LEFT JOIN orders ON orders.user_id = active_users.id
WHERE active_users.id = :id
GROUP BY active_users.id;
// Generated:
readonly class GetActiveUserWithOrdersRow
{
public function __construct(
public int $id,
public string $email,
/** @var Order[] */
public array $orders,
) {}
}
Use case 5 — Recursive CTE for hierarchical data
Shared CTEs support any valid MySQL CTE syntax, including RECURSIVE — useful for category trees, org charts, or multi-level BOMs:
-- database/ctes/categories.sql
-- @cte category_tree
-- Full recursive category tree from root to leaf.
WITH RECURSIVE category_tree AS (
SELECT id, name, parent_id, 0 AS depth
FROM categories
WHERE parent_id IS NULL
UNION ALL
SELECT c.id, c.name, c.parent_id, ct.depth + 1
FROM categories c
INNER JOIN category_tree ct ON ct.id = c.parent_id
)
SELECT id, name, parent_id, depth FROM category_tree
-- @name ListProductsInCategory
-- @class Products
-- @returns :many
-- @use category_tree
SELECT
products.id,
products.name,
category_tree.name AS category_name,
category_tree.depth AS category_depth
FROM products
INNER JOIN category_tree ON category_tree.id = products.category_id
ORDER BY category_tree.depth, products.name;
- A query using
@usemust not also declare an inlineWITHclause — mixing the two is a hard error with a clear message. - Duplicate CTE names in a single
@uselist are silently deduplicated. - CTEs are injected in the order they appear in the
@useannotation — relevant when one CTE references another. - Global
ctes:(root ofsqlc.yaml) are available to every target. Per-targetctes:add on top. - CTEs are transparent to the Analyzer: all annotations (
@searchable,@json,@embed,@optional,@partial,@cursor) work normally on queries using@use. - The
--verifyand--diffflags work with CTE-injected queries.
@calls
Used with :transaction to declare which methods are called in sequence inside a single database transaction. See the :transaction return type for a full example.
-- @name TransferFunds
-- @calls debitAccount,creditAccount
-- @returns :transaction
-- (no SQL body — the transaction orchestrates other methods)
@partial — optional UPDATE fields (PATCH pattern)
Marks an UPDATE query as a partial update. Any parameter that appears inside a COALESCE(:param, column) expression in the SET clause becomes optional (?type $param = null) in the generated method. When null is passed, the column is left unchanged — no PHP-side conditionals required.
Parameters in the WHERE clause remain required. Only valid on :exec UPDATE queries.
-- @name PatchUser
-- @partial
-- @returns :exec
UPDATE users SET
email = COALESCE(:email, email),
name = COALESCE(:name, name),
active = COALESCE(:active, active)
WHERE id = :id;
// Generated method — required params first, optional params last
public function patchUser(
int $id, // WHERE param — required
?string $email = null, // SET param — optional
?string $name = null, // SET param — optional
?int $active = null, // SET param — optional
): void
// Update only the email — name and active are left unchanged
$query->patchUser(id: 1, email: 'new@example.com');
// Update only active status
$query->patchUser(id: 1, active: 0);
// Update multiple fields at once
$query->patchUser(id: 1, email: 'new@example.com', name: 'Alice', active: 1);
// WHERE params can also be composite
// UPDATE users SET name = COALESCE(:name, name) WHERE id = :id AND tenant_id = :tenant_id
$query->patchUser(id: 1, tenant_id: 42, name: 'Bob');
How it works
@partial does not rewrite the SQL. The COALESCE is written by you and is the source of truth: when PDO binds null to :email, COALESCE(NULL, email) evaluates to the existing column value — effectively a no-op for that field. The generator detects which params appear inside COALESCE(:param, ...) in the SET region and marks them optional.
Comparison with @optional
| Feature | @optional | @partial |
|---|---|---|
| Works in | WHERE clause | SET clause |
| SQL rewriting | Yes — rewrites col = :p to (:p_chk IS NULL OR col = :p) | No — you write the COALESCE yourself |
| Use case | Skip a filter when null (SELECT/UPDATE WHERE) | Skip a SET field when null (UPDATE PATCH) |
| Valid on | :many, :many-paginated, :exec | :exec UPDATE only |
| Can combine | Yes — @partial + @optional on same query | Yes |
@partial and @optional can be combined. Use @partial for optional SET fields and @optional for an optional WHERE filter on the same UPDATE query.
-- @name PatchActiveUsers
-- @partial
-- @optional country_id ← optional WHERE filter (rewritten by SqlRewriter)
-- @returns :exec
UPDATE users SET
name = COALESCE(:name, name)
WHERE active = 1 AND country_id = :country_id;
// Filter by country (optional) and update name (optional)
public function patchActiveUsers(
?int $country_id = null, // WHERE — optional
?string $name = null, // SET — optional
): void
Return types reference
| Type | PHP return | Behaviour |
|---|---|---|
| :many | ModelClass[] | Array of rows — empty array if no rows |
| :many-paginated | ModelClass[] | Array with optional ?int $limit / int $offset. null limit returns all rows. |
| :paginated | PaginatedResult<ModelClass> | One call returns items + total + pages + navigation metadata. Executes COUNT + SELECT internally. limit defaults to 10. |
| :cursor | CursorResult<ModelClass> | Keyset pagination — O(1) at any depth. No COUNT query. Requires @cursor. v2.11.2 |
| :one | ModelClass | Single row — throws RuntimeException if not found |
| :opt | ModelClass|null | Single row — returns null if not found |
| :exec | void | INSERT / UPDATE / DELETE — no return value |
| :batch | int | Same query run N times in a transaction — returns total rows affected |
| :transaction | void | Calls multiple @calls methods inside one transaction |
:cursor v2.11.2
Keyset (cursor) pagination. Unlike offset pagination (:paginated), cursor pagination is O(1) regardless of page depth — it uses a WHERE clause anchored to the last seen values instead of scanning and discarding rows with OFFSET. Returns a CursorResult with items, hasMore/hasPrev flags, and opaque nextCursor/prevCursor tokens for bidirectional navigation.
Requires @cursor col [DIR], col [DIR] to declare the cursor columns.
-- @name ListOrders
-- @class Order
-- @cursor created_at DESC, id DESC
-- @returns :cursor
SELECT id, user_id, total, created_at
FROM orders
WHERE user_id = :userId
ORDER BY created_at DESC, id DESC;
// Generated signature
public function listOrders(int $userId, ?string $after = null, int $limit = 20): CursorResult
// Page 1 — no cursor
$page1 = $repo->listOrders(userId: 42);
// Page 2 — pass nextCursor from previous page
$page2 = $repo->listOrders(userId: 42, after: $page1->nextCursor);
// CursorResult properties
$page1->items; // Order[] — rows for this page
$page1->hasMore; // bool — true when next page exists
$page1->hasPrev; // bool — true when previous page exists (false on first page)
$page1->nextCursor; // string|null — pass as $after; null on last page
$page1->prevCursor; // string|null — pass as $before; null on first page
// Navigate backward
$back = $repo->listOrders(userId: 42, before: $page2->prevCursor);
// Debug: decode the cursor token
CursorResult::decodeCursor($page1->nextCursor);
// → ['created_at' => '2024-01-15 10:30:00', 'id' => 42]
:paginated | :cursor | |
|---|---|---|
| DB queries per page | 2 (COUNT + SELECT) | 1 (SELECT only) |
| Performance at depth | O(offset) — slows on deep pages | O(1) — always fast |
| Total count / page count | ✅ always available | ❌ not computed |
| Jump to arbitrary page | ✅ via offset | ❌ walk forward only |
| Consistent reads | Inserts may cause skips | ✅ cursor anchors to data |
| Ideal for | Admin tables, numbered pages, reports | Feeds, infinite scroll, large tables |
See the Cursor pagination guide for parameter combinations, @optional, @searchable, and CursorResult details.
:many
Returns all matching rows as an array. Returns an empty array when no rows match — never throws.
-- @name ListActiveUsers
-- @returns :many
SELECT * FROM users WHERE active = 1;
/** @return User[] */
public function listActiveUsers(): array
When the SELECT includes columns from multiple tables (JOIN), sqlc-php generates a result DTO (ListActiveUsersRow) instead of the model class.
:many-paginated
Like :many but adds ?int $limit = null and int $offset = 0 parameters. When $limit is null (the default), all rows are returned — no LIMIT clause is applied. Two prepared statements are generated internally (one with, one without LIMIT).
-- @name ListUsers
-- @returns :many-paginated
SELECT * FROM users ORDER BY created_at DESC;
/** @return User[] */
public function listUsers(?int $limit = null, int $offset = 0): array
$all = $query->listUsers(); // all rows
$page1 = $query->listUsers(20); // first 20
$page2 = $query->listUsers(20, 20); // rows 21-40
Combine with @counted to get the total for pagination UI. Combine with @searchable for dynamic filters.
:one
Returns a single row. Throws RuntimeException if the query returns no rows. Use when the row is guaranteed to exist (e.g., lookup by primary key with a foreign key constraint).
-- @name GetUser
-- @returns :one
SELECT * FROM users WHERE id = :id;
public function getUser(int $id): User // throws if not found
:opt
Returns a single row or null. Use when the row may not exist.
-- @name FindUserByEmail
-- @returns :opt
SELECT * FROM users WHERE email = :email;
public function findUserByEmail(string $email): ?User
:exec
For INSERT, UPDATE, DELETE. Returns void.
-- @name CreateUser
-- @returns :exec
INSERT INTO users (email, active) VALUES (:email, :active);
-- @name DeleteUser
-- @returns :exec
DELETE FROM users WHERE id = :id;
-- @name UpdateActive
-- @returns :exec
UPDATE users SET active = :active WHERE id = :id;
public function createUser(string $email, int $active): void
public function deleteUser(int $id): void
public function updateActive(int $active, int $id): void
:batch
Executes the same query once per row in an array, inside a single transaction. Returns the total number of rows affected. Rolls back automatically on failure.
-- @name InsertUsers
-- @returns :batch
INSERT INTO users (email, active) VALUES (:email, :active);
/** @param array $rows */
public function insertUsers(array $rows): int
$count = $query->insertUsers([
['email' => 'a@example.com', 'active' => 1],
['email' => 'b@example.com', 'active' => 1],
]);
:transaction
Calls multiple existing :exec methods of the same Query class inside one database transaction. Requires @calls and @class.
-- @name DebitAccount
-- @class Transfer
-- @returns :exec
UPDATE accounts SET balance = balance - :amount WHERE id = :account_id;
-- @name CreditAccount
-- @class Transfer
-- @returns :exec
UPDATE accounts SET balance = balance + :amount WHERE id = :account_id;
-- @name TransferFunds
-- @class Transfer
-- @calls debitAccount,creditAccount
-- @returns :transaction
public function transferFunds(int $amount, int $account_id): void
{
$this->pdo->beginTransaction();
try {
$this->debitAccount($amount, $account_id);
$this->creditAccount($amount, $account_id);
$this->pdo->commit();
} catch (\Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
}
CLI reference
php vendor/bin/sqlc-php [options] [config]
| Flag | Description |
|---|---|
-h, --help | Show help message and exit |
-v, --version | Print version and exit |
(no flag) |
Generate PHP files from schema + queries. Config defaults to sqlc.yaml. |
--dry-run |
Print generated code to stdout. Nothing is written to disk. |
--diff |
Show a unified diff of what would change. Nothing is written. Exits 1 if there are changes. |
--verify |
Exit 1 if any generated file is out of date — without writing anything. Ideal for CI pipelines. |
--watch |
Watch schema and query files for changes and regenerate automatically. Press Ctrl+C to stop. |
--interval=N |
Polling interval for --watch in milliseconds. Default: 500. Minimum: 100. |
--generate-schema |
Connect to the database: configured in sqlc.yaml and extract the schema. Writes to the first schema: file. |
--schema-output=PATH |
Override the output path for --generate-schema. |
Examples
php vendor/bin/sqlc-php sqlc.yaml
php vendor/bin/sqlc-php --verify sqlc.yaml
php vendor/bin/sqlc-php --dry-run sqlc.yaml
php vendor/bin/sqlc-php --diff sqlc.yaml
php vendor/bin/sqlc-php --watch sqlc.yaml
php vendor/bin/sqlc-php --watch --interval=250 sqlc.yaml
php vendor/bin/sqlc-php --generate-schema sqlc.yaml
php vendor/bin/sqlc-php --generate-schema --schema-output=db/schema.sql sqlc.yaml
php vendor/bin/sqlc-php --version
Pattern: Basic SELECT
SELECT * — returns the model class
When the query selects all columns from a single table, sqlc-php returns the generated model class directly.
-- @name ListUsers
-- @returns :many
SELECT * FROM users;
-- @name GetUser
-- @returns :one
SELECT * FROM users WHERE id = :id;
-- @name FindByEmail
-- @returns :opt
SELECT * FROM users WHERE email = :email;
SELECT specific columns — generates a result DTO
When the query selects a subset of columns (or columns from multiple tables), a result DTO is generated automatically.
-- @name ListUserSummary
-- @returns :many
SELECT id, email, active FROM users;
Generates ListUserSummaryRow.php with exactly those three properties. Use @dto to rename it.
Pattern: JOINs & DTOs
-- @name GetUserWithRole
-- @dto UserWithRole
-- @embed Role role__
-- @returns :one
SELECT
u.id,
u.email,
r.name AS role__name,
r.slug AS role__slug
FROM users u
JOIN roles r ON r.id = u.role_id
WHERE u.id = :id;
// Generated: UserWithRole.php
readonly class UserWithRole {
public function __construct(
public int $id,
public string $email,
public Role $role, // ← nested via @embed
) {}
}
// Generated: Role.php
readonly class Role {
public function __construct(
public string $name,
public string $slug,
) {}
}
Pattern: Aggregates and IF()
sqlc-php infers types from SQL expressions. Numeric literals (0, 42) resolve to int. Arithmetic expressions (col - MAX(col)) resolve from the left operand. IF(cond, true_val, false_val) resolves from the true branch.
-- @name ListBillingConfig
-- @class BillingConfig
-- @returns :many
SELECT
bc.id,
bc.end_num,
MAX(m.voucher_number) AS max_voucher,
IF(MAX(m.voucher_number) >= bc.end_num, 0,
bc.end_num - MAX(m.voucher_number)) AS remaining
FROM billing_config bc
LEFT JOIN memory m ON m.voucher_type = CASE
WHEN bc.country_id = 164 THEN 'factExp' ELSE 'factTicket' END
GROUP BY bc.id;
Result: int $remaining (not string) because 0 is a numeric literal and bc.end_num - MAX(...) resolves from bc.end_num which is INT.
Expression type inference
| Expression | Resolved as |
|---|---|
COUNT(*), COUNT(col) | int |
MAX(col), MIN(col) | ?type of col |
SUM(col), AVG(col) | ?float |
LENGTH(), CHAR_LENGTH() | int |
CONCAT(), UPPER(), LOWER() | ?string |
COALESCE(col, default) | type of col (non-nullable) |
IFNULL(col, default) | type of col (non-nullable) |
NULLIF(a, b) | ?type of a |
IF(cond, val, other) | type of val (or other if val is NULL) |
0, 42 (integer literal) | int |
3.14 (float literal) | float |
col - MAX(col) (arithmetic) | type of left operand |
CAST(col AS SIGNED) | int |
CAST(col AS CHAR) | string |
NOW(), CURDATE() | \DateTimeImmutable |
Pattern: IN() clauses
Use a single named placeholder for an IN() clause. sqlc-php expands it to individual placeholders at runtime.
-- @name GetUsersByIds
-- @returns :many
SELECT * FROM users WHERE id IN (:ids);
/** @param int[] $ids */
public function getUsersByIds(array $ids): array
$users = $query->getUsersByIds([1, 2, 3, 4]);
IN() method throws InvalidArgumentException. Always validate before calling.
Pattern: MySQL ENUM → PHP backed enum
MySQL ENUM columns are automatically mapped to PHP 8.1+ backed enums.
-- schema.sql
CREATE TABLE orders (
id INT AUTO_INCREMENT PRIMARY KEY,
status ENUM('pending', 'paid', 'cancelled') NOT NULL DEFAULT 'pending'
);
// Generated: OrderStatus.php
enum OrderStatus: string {
case Pending = 'pending';
case Paid = 'paid';
case Cancelled = 'cancelled';
}
// In Order.php model:
public OrderStatus $status
Pattern: JSON columns
CREATE TABLE products (
id INT PRIMARY KEY,
metadata JSON NULL
);
// Generated model property
public ?array $metadata // automatically json_decode()'d in fromRow()
Generated file structure
Model class example (User.php)
// Generated — do not edit manually
readonly class User
{
public function __construct(
public int $id,
public string $email,
public int $active,
public ?string $name,
public \DateTimeImmutable $createdAt,
) {}
public static function fromRow(array $row): self
{
return new self(
id: (int) $row['id'],
email: $row['email'],
active: (int) $row['active'],
name: $row['name'] ?? null,
createdAt: new \DateTimeImmutable($row['created_at']),
);
}
}
Query class example (UserQuery.php)
// Generated — do not edit manually
class UserQuery implements UserQueryInterface
{
public function __construct(private readonly PDO $pdo) {}
/** @return User[] */
public function listActiveUsers(): array
{
$stmt = $this->pdo->prepare('SELECT * FROM users WHERE active = 1');
$stmt->execute();
return array_map(
static fn(array $row): User => User::fromRow($row),
$stmt->fetchAll(PDO::FETCH_ASSOC),
);
}
public function getUser(int $id): User
{
$stmt = $this->pdo->prepare('SELECT * FROM users WHERE id = :id');
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row === false) {
throw new RuntimeException("getUser: no row found for id={$id}");
}
return User::fromRow($row);
}
}
Interface example (UserQueryInterface.php)
// Generated — do not edit manually
interface UserQueryInterface
{
/** @return User[] */
public function listActiveUsers(): array;
public function getUser(int $id): User;
public function findByEmail(string $email): ?User;
}
Basic usage
$pdo = new PDO('mysql:host=localhost;dbname=myapp', 'user', 'pass', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$userQuery = new UserQuery($pdo);
$users = $userQuery->listActiveUsers();
$user = $userQuery->getUser(1);
$found = $userQuery->findByEmail('hello@example.com');
lastQuery(), logger & afterQuery hook
Every generated Query class exposes three optional features for observability: a lastQuery() accessor that records the last executed SQL, a PSR-3 logger for automatic query logging, and an $afterQuery callable hook for custom integrations like Laravel Debugbar.
Constructor signature
public function __construct(
PDO $pdo,
?LoggerInterface $logger = null, // PSR-3 — Telescope, files, etc.
?Closure $afterQuery = null, // custom hook — Debugbar, metrics, etc.
)
All parameters after $pdo are optional and default to null. Existing code that constructs new UserRepository($pdo) continues to work without any changes.
lastQuery() — post-execution inspection
$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
$q->bindings(); // [':active' => [1, PDO::PARAM_INT], ...]
$q->values(); // [':active' => 1]
$q->queryName; // 'listActiveUsers'
$q->durationMs; // 4.217 — execution time in milliseconds (float)
$q->cacheKey(); // stable md5 for caching
Execution timing
Every generated method wraps $stmt->execute() with hrtime(true) to measure the real round-trip time to the database. The result is stored as durationMs (milliseconds, float precision) in the QueryObject.
// hrtime(true) → nanoseconds. Dividing by 1_000_000 → milliseconds.
// $__t0 = hrtime(true);
// $stmt->execute();
// $this->lastQuery = $this->lastQuery->withDuration((hrtime(true) - $__t0) / 1_000_000);
$q = $repo->lastQuery();
echo $q->durationMs; // e.g. 4.217 ms
echo round($q->durationMs, 1) . 'ms'; // "4.2ms"
// Detect slow queries in the afterQuery hook
$repo = new UserRepository(
pdo: $pdo,
afterQuery: function (QueryObject $q): void {
if ($q->durationMs > 100) {
$this->logger->warning(
"Slow query [{$q->queryName}]: {$q->durationMs}ms",
['sql' => $q->toString(), 'bindings' => $q->values()],
);
}
},
);
$stmt->execute() — the actual database round-trip. prepare() and bindValue() are excluded. For :batch, the entire transaction (all rows + commit) is measured as a single duration. Uses hrtime(true) which is monotonic — immune to NTP and clock adjustments.
PSR-3 logger — automatic query logging with duration
When a PSR-3 logger is provided, every executed method logs at DEBUG level with the format queryName [Xms]: SQL.
$repo = new UserRepository(pdo: $pdo, logger: $logger);
// After $repo->listActiveUsers(active: 1), the logger receives:
// message: "listActiveUsers [4.217ms]: SELECT * FROM users WHERE active = :active"
// context: [':active' => 1]
app(Psr\Log\LoggerInterface::class) resolves to the application logger automatically. Queries logged at debug level appear in Laravel Telescope under the Logs tab — with timing included in the message.
afterQuery hook — custom integrations with timing
// Laravel Debugbar — shows query name, SQL, and duration
$repo = new UserRepository(
pdo: $pdo,
afterQuery: function (QueryObject $q) use ($app): void {
$collector = \Debugbar::getCollector('queries');
// Option A — toDebugSql() + empty bindings (recommended)
// SQL has values already interpolated; no further processing by Debugbar.
// Works correctly with @optional queries (avoids '[,1]' display bug).
$qe = new QueryExecuted(
$q->toDebugSql(), // "SELECT ... WHERE (NULL IS NULL OR active = 1)"
[],
$q->durationMs,
$app->make('db')->connection(),
);
// Option B — toString() + toDebugBindings()
// toDebugBindings() returns a flat array of values, filtering out internal
// _chk params (@optional) and :limit/:offset (:many-paginated).
// $qe = new QueryExecuted(
// $q->toString(), // SQL with :placeholders
// $q->toDebugBindings(), // [1, 164, ...] — flat, _chk filtered
// $q->durationMs,
// $app->make('db')->connection(),
// );
$collector->addQuery($qe);
},
);
// Slow query alerting
$repo = new UserRepository(
pdo: $pdo,
afterQuery: function (QueryObject $q): void {
if ($q->durationMs > 100) {
Log::warning("Slow query: {$q->queryName} took {$q->durationMs}ms");
}
},
);
// Both logger + hook
$repo = new UserRepository(
pdo: $pdo,
logger: app(LoggerInterface::class), // PSR-3 → Telescope
afterQuery: fn(QueryObject $q) =>
\Debugbar::addMessage(
sprintf('[%.1fms] %s', $q->durationMs, $q->toString()),
'queries'
),
);
Testing SQL without a database
$pdo = $this->createMock(PDO::class);
$stmt = $this->createMock(PDOStatement::class);
$stmt->method('fetchAll')->willReturn([]);
$pdo->method('prepare')->willReturn($stmt);
$repo = new UserRepository($pdo);
$users = $repo->listActiveUsers(active: 1);
$this->assertStringContainsString('WHERE', $repo->lastQuery()->toString());
$this->assertSame(1, $repo->lastQuery()->values()[':active']);
// durationMs will be near 0 (mock execute is instant)
QueryObject API reference
| Method / Property | Returns | Description |
|---|---|---|
toString() | string | SQL with named PDO placeholders — safe to log |
(string) $q | string | Same as toString() |
toDebugSql() | string | SQL with values interpolated inline — debug only, never execute |
toDebugBindings() | list<mixed> | Flat array of values for Debugbar/Laravel QueryExecuted. Filters out internal _chk params (@optional) and :limit/:offset (:many-paginated). |
bindings() | array | All bound parameters with PDO type constants — internal format |
values() | array | Named bound values (keyed by placeholder) — use as PSR-3 log context |
cacheKey() | string | Stable md5 key — unique per SQL + values |
paramCount() | int | Number of bound parameters |
$q->queryName | string | Name of the method that produced this query |
$q->durationMs | float | Execute time in milliseconds. 0.0 if not yet measured. |
$q->isBatch | bool | true for :batch queries |
$q->batchCount | int | Rows processed (batch only) |
toDebugSql() to PDO. Values are interpolated without proper escaping — for logging and debugging only.
lastQuery(), $logger, and $afterQuery are not in the generated *Interface.php — they are infrastructure details, not domain contracts.
Laravel integration
1. Bind in a Service Provider
// app/Providers/AppServiceProvider.php
use App\Database\UserQueryInterface;
use App\Database\UserQuery;
public function register(): void
{
$this->app->bind(UserQueryInterface::class, function ($app) {
return new UserQuery($app['db']->getPdo());
});
}
2. Inject into a controller
class UserController extends Controller
{
public function __construct(
private readonly UserQueryInterface $userQuery,
) {}
public function index(): JsonResponse
{
return response()->json($this->userQuery->listActiveUsers());
}
}
3. Testing with the interface
$this->app->bind(UserQueryInterface::class, fn() => new class implements UserQueryInterface {
public function listActiveUsers(): array { return []; }
public function getUser(int $id): User { throw new RuntimeException('not found'); }
// ...
});
Running the test suite
# PHPUnit test suite (960 tests)
./vendor/bin/phpunit
Source structure
Extension traits
Every generated model and DTO is a readonly class that sqlc-php overwrites on every generate run. Extension traits let you add domain methods — isActive(), toApiArray(), isExpired() — without losing them on re-generation.
Activation
Add extensions: to the out: map. One line activates the entire feature:
targets:
- namespace: "App\\Database"
scoped_dtos: true # extensions mirror this structure
out:
queries: app/Database/Repositories
models: app/Database/Models
dtos: app/Database/DTOs
enums: app/Database/Enums
interfaces: app/Database/Contracts
criterias: app/Database/Criterias
extensions: app/Database/Extensions # ← add this line
Without this line the feature is completely inactive — full backward compatibility.
What gets generated
app/Database/
Models/
User.php ← readonly class User { use UserExtension; ... }
BillingConfig.php ← readonly class BillingConfig { use BillingConfigExtension; ... }
DTOs/
ReserveBilling/
GetByReserveId/
ReserveBilling.php ← use ReserveBillingExtension
Customer.php ← use CustomerExtension
Reserve.php ← use ReserveExtension
Extensions/ ← these files are NEVER overwritten
Models/
UserExtension.php
BillingConfigExtension.php
DTOs/ ← mirrors DTOs/ structure exactly
ReserveBilling/
GetByReserveId/
ReserveBillingExtension.php
CustomerExtension.php
ReserveExtension.php
The generated model (with extensions active)
use App\Database\Extensions\Models\UserExtension;
/**
* DTO for the `users` table.
* Generated by sqlc-php — do not edit manually.
*
* @mixin UserExtension
*/
readonly class User
{
use UserExtension;
public function __construct(
public int $id,
public string $email,
public int $active,
) {}
public static function fromRow(array $row): self { ... }
}
The scaffold (written once — then yours)
The scaffold uses two complementary docblock tags to give every IDE and static analyser complete type knowledge of the host class properties inside the trait:
namespace App\Database\Extensions\Models;
/**
* Extension trait for the `User` model.
* Extends the `User` table model.
*
* ╔══════════════════════════════════════════════════════════╗
* ║ This file is yours — sqlc-php will NEVER overwrite it. ║
* ╚══════════════════════════════════════════════════════════╝
*
* Add domain methods that operate on `User` properties.
* All properties of the model are available via $this.
*
* @mixin \App\Database\Models\User
* @property int $id
* @property string $email
* @property int $active
*
* Example:
// public function isActive(): bool
// {
// return $this->active === 1;
// }
*/
trait UserExtension
{
// Your methods here
}
How the docblock tags provide type context
Two tags work together to cover all major IDEs and static analysers:
| Tag | What it does | Understood by |
|---|---|---|
@mixin \Full\Class\Name |
Resolves $this as the host class inside the trait — full autocomplete for all properties and methods of the host. |
PhpStorm, Intelephense, Psalm |
@property type $name |
Declares each property individually. PHPStan uses these when @mixin resolution is partial. Also serves as human-readable documentation of the available surface. |
PHPStan, PhpStorm, Intelephense, Psalm |
@property list in your extension file will not update automatically. The @mixin tag always stays accurate because it points to the generated (always-current) host class — so PhpStorm and Intelephense will still know about the new property. Update the @property tags manually if you rely on PHPStan strict mode for the new column.
Use cases
// 1. Domain logic
/**
* @mixin \App\Database\Models\User
* @property int $id
* @property string $email
* @property int $active
*/
trait UserExtension
{
public function isActive(): bool
{
return $this->active === 1; // $this->active is int — fully typed
}
public function displayName(): string
{
return ucwords(explode('@', $this->email)[0]); // $this->email is string
}
}
// 2. Temporal logic
/**
* @mixin \App\Database\Models\BillingConfig
* @property \DateTimeImmutable $start_date
* @property ?\DateTimeImmutable $expiration_date
*/
trait BillingConfigExtension
{
public function isExpired(): bool
{
return $this->expiration_date !== null
&& $this->expiration_date < new \DateTimeImmutable();
}
}
// 3. API serialisation
/**
* @mixin \App\Database\DTOs\Order\GetDetails\OrderDetails
* @property int $id
* @property float $total
* @property string $status
*/
trait OrderDetailsExtension
{
public function toApiArray(): array
{
return [
'id' => $this->id,
'total' => number_format($this->total, 2),
'status' => $this->status,
];
}
}
// 4. Implementing a domain interface
/**
* @mixin \App\Database\Models\User
* @property string $role
*/
trait UserExtension
{
public function hasRole(string $role): bool { return $this->role === $role; }
}
// Then in sqlc.yaml the query class returns User,
// and User implements the interface: readonly class User implements HasRoles { use UserExtension; }
--verify and --diff. sqlc-php never compares or reports changes in your extension files — they are invisible to the tooling.
@mixin tag will immediately reflect the new property in IDEs that resolve it. Add a @property type $newColumn declaration manually to the scaffold if you need PHPStan to know about it too.
Cursor pagination guide v2.11.2
This guide covers all cursor pagination use cases: basic usage, required params, optional params, @searchable criteria, and the CursorResult API.
1 — Basic (no user params)
-- @name ListOrders
-- @class Order
-- @cursor created_at DESC, id DESC
-- @returns :cursor
SELECT id, user_id, total, created_at
FROM orders
ORDER BY created_at DESC, id DESC;
public function listOrders(?string $after = null, ?string $before = null, int $limit = 20): CursorResult
// Forward navigation
$page1 = $repo->listOrders();
$page2 = $repo->listOrders(after: $page1->nextCursor);
$page3 = $repo->listOrders(after: $page2->nextCursor);
// Backward navigation
$back = $repo->listOrders(before: $page2->prevCursor); // returns page 1 items
// CursorResult
$page2->hasMore; // true — page 3 exists
$page2->hasPrev; // true — page 1 exists
$page2->nextCursor; // pass as $after → page 3
$page2->prevCursor; // pass as $before → page 1
2 — With required parameters
Required params appear before $after and $limit in the signature. Pass the same values on every page — the cursor only encodes the position, not the filter context.
-- @name ListOrdersByUser
-- @class Order
-- @cursor created_at DESC, id DESC
-- @returns :cursor
SELECT id, user_id, total, created_at
FROM orders
WHERE user_id = :userId
ORDER BY created_at DESC, id DESC;
public function listOrdersByUser(int $userId, ?string $after = null, ?string $before = null, int $limit = 20): CursorResult
$page1 = $repo->listOrdersByUser(userId: 42);
$page2 = $repo->listOrdersByUser(userId: 42, after: $page1->nextCursor);
$back = $repo->listOrdersByUser(userId: 42, before: $page2->prevCursor);
3 — With optional parameters (@optional)
Write the SQL naturally and let @optional handle the rewrite. Do not manually write the (:param_chk IS NULL OR ...) pattern — the rewriter handles it automatically, and combining both would double the condition.
-- @name ListOrdersByStatus
-- @class Order
-- @cursor created_at DESC, id DESC
-- @optional status
-- @returns :cursor
SELECT id, total, status, created_at
FROM orders
WHERE status = :status ← natural form; @optional rewrites it
ORDER BY created_at DESC, id DESC;
public function listOrdersByStatus(?string $status = null, ?string $after = null, ?string $before = null, int $limit = 20): CursorResult
// Filter applied — forward and backward
$page1 = $repo->listOrdersByStatus(status: 'paid');
$page2 = $repo->listOrdersByStatus(status: 'paid', after: $page1->nextCursor);
$back = $repo->listOrdersByStatus(status: 'paid', before: $page2->prevCursor);
// No filter
$all = $repo->listOrdersByStatus();
4 — With @searchable (dynamic WHERE filters)
When @cursor and @searchable are combined, the generated Criteria class exposes where() and orGroup() but not orderBy(). The ORDER BY is fixed by @cursor — allowing dynamic ordering would invalidate the cursor between pages.
-- @name ListOrders
-- @class Order
-- @searchable
-- @cursor created_at DESC, id DESC
-- @returns :cursor
SELECT id, user_id, total, status, created_at
FROM orders
ORDER BY created_at DESC, id DESC;
public function listOrders(?OrderCriteria $criteria = null, ?string $after = null, ?string $before = null, int $limit = 20): CursorResult
$criteria = (new OrderCriteria)
->whereStatus('=', 'paid')
->whereUserId('=', 42);
$page1 = $repo->listOrders($criteria);
$page2 = $repo->listOrders($criteria, after: $page1->nextCursor);
$back = $repo->listOrders($criteria, before: $page2->prevCursor);
5 — With @counted (total row count)
Add @counted to generate a companion {name}Count() method that returns the total number of rows matching the user filters, independent of cursor position. The count uses the original user SQL wrapped in a subquery — cursor WHERE conditions are excluded.
-- @name ListOrders
-- @class Order
-- @cursor created_at DESC, id DESC
-- @counted ← generates listOrdersCount()
-- @returns :cursor
SELECT id, user_id, total, created_at
FROM orders
WHERE user_id = :userId
ORDER BY created_at DESC, id DESC;
// Generated — two independent methods
public function listOrders(int $userId, ?string $after = null, ?string $before = null, int $limit = 20): CursorResult
public function listOrdersCount(int $userId): int
// Recommended usage: call Count() once on the first page load only
$page1 = $repo->listOrders(userId: 42);
$total = $repo->listOrdersCount(userId: 42); // ← one extra query, called once
// Subsequent pages — no COUNT needed
$page2 = $repo->listOrders(userId: 42, after: $page1->nextCursor);
$page3 = $repo->listOrders(userId: 42, after: $page2->nextCursor);
The COUNT SQL wraps the original user query in a subquery, so it correctly handles JOINs, @optional conditions, and subqueries in the original SQL:
-- Generated COUNT SQL
SELECT COUNT(*) AS _total
FROM (
SELECT id, user_id, total, created_at FROM orders WHERE user_id = :userId
) AS _cursor_total
$after / $before navigation.
CursorResult API
readonly class CursorResult
{
public array $items; // T[] — rows for this page (always in original ORDER BY direction)
public bool $hasMore; // true when next page exists (forward)
public bool $hasPrev; // true when previous page exists; false on the very first page
public ?string $nextCursor; // pass as $after to fetch next page; null on last page
public ?string $prevCursor; // pass as $before to fetch previous page; null on first page
}
// Decode a cursor for debugging or testing
CursorResult::decodeCursor($page1->nextCursor);
// → ['created_at' => '2024-01-15 10:30:00', 'id' => 42]
// Encode (used internally; available if needed)
$token = CursorResult::encodeCursor(['created_at' => '2024-01-15', 'id' => 42]);
InvalidArgumentException. Pass one or neither.
How forward and backward navigation works
For @cursor created_at DESC, id DESC the generator produces two SQL strings internally:
- Forward ($after): injects
AND (created_at, id) < (:cursor)beforeORDER BY created_at DESC, id DESC. The_chk IS NULLguard makes the condition a no-op on the first page. - Backward ($before): injects
AND (created_at, id) > (:cursor)beforeORDER BY created_at ASC, id ASC(all directions inverted), then callsarray_reverse()to restore the original DESC order. - Both directions fetch
$limit + 1rows to detect whether another page exists, without a COUNT query. nextCursoris built from the last row's cursor column values.prevCursoris built from the first row.
LIMIT internally (as LIMIT :__limit = limit + 1). If you include LIMIT :limit, it will be stripped automatically.
created_at, id) must appear in the SELECT list — their values are read from each row to build nextCursor. If they are not selected, the cursor token will be empty and pagination will break.
Criteria::fromArray() v2.12.0
Build a Criteria instance from a plain array of positional filter tuples. Useful when filters come from an external source (HTTP request, session, database) or when you want to construct criteria programmatically without method chaining.
Syntax
SomeCriteria::fromArray([
[column, operator, value], // most operators
[column, operator], // IS NULL / IS NOT NULL (no value needed)
[column, 'in', [...]], // IN: value is an array
[column, 'not_in', [...]], // NOT IN: value is an array
[column, 'between', [from, to]], // BETWEEN: value is [from, to]
]);
Full example
$criteria = OrderCriteria::fromArray([
['user_id', '=', 42],
['status', '!=', 'cancelled'],
['total', '>=', 100.0],
['name', 'like', 'john'], // → LIKE '%john%'
['name', 'starts_with', 'jo'], // → LIKE 'jo%'
['tag', 'in', ['vip', 'gold']], // → IN (:tag_0, :tag_1)
['deleted_at', 'is_null'], // no value — 2-element tuple
['created_at', 'between', ['2024-01-01', '2024-12-31']],
]);
$results = $repo->listOrders($criteria);
Supported operators
| Operator string(s) | Meaning | Value |
|---|---|---|
= eq | Equal | scalar |
!= <> neq | Not equal | scalar |
> gt | Greater than | scalar |
< lt | Less than | scalar |
>= gte | Greater or equal | scalar |
<= lte | Less or equal | scalar |
like | LIKE %value% | string |
starts_with | LIKE value% | string |
ends_with | LIKE %value | string |
in | IN (...) | array |
not_in | NOT IN (...) | array |
is_null null | IS NULL | none — 2-element tuple |
is_not_null not_null | IS NOT NULL | none — 2-element tuple |
between | BETWEEN from AND to | [from, to] |
Column validation
fromArray() validates column names against the same $allowedColumns list used by orderBy(). An unknown column throws InvalidArgumentException immediately — before any SQL is constructed.
// Throws: "column 'nonexistent' is not allowed. Allowed: id, user_id, status, ..."
OrderCriteria::fromArray([['nonexistent', '=', 1]]);
fromArray() accepts any scalar value — type validation is the caller's responsibility. For strict type checking, prefer the fluent API (->whereUserIdEq(int $value)) which is validated by PHP's type system. fromArray() is most useful when filters come from external sources like HTTP requests, where values are always strings anyway.
AND. To combine with OR, mix fromArray() with orGroup():
$base = OrderCriteria::fromArray([['user_id', '=', 42]]);
$with_or = $base->orGroup(fn($c) => $c->whereStatusEq('vip'));
Combining fromArray() with the fluent API
fromArray() returns the same immutable Criteria instance — you can chain fluent methods on the result:
$criteria = OrderCriteria::fromArray($request->input('filters', []))
->whereDeletedAtIsNull() // always exclude deleted
->orderByCreatedAt('DESC'); // always sort newest first
Changelog
@comment lost when placed before or around @name hotfix- Fixed:
@commentbefore@namewas silently dropped. The block splitter used@nameas the start marker, so any annotations before it were discarded. The carry-forward logic now prepends them to the next block. - Fixed:
@commenttext containing@nameor@classcorrupted the query name. Unanchored regexes matched inside comment text (e.g.-- @comment This appears between @name and @class.set$name = 'and'). Both regexes are now anchored with^. - 7 new regression tests in
CommentAnnotationTest.
@comment annotation — method docblock descriptions feature- New
@comment textannotation — adds a human-readable description to the generated method docblock and its matching interface method, placed before@paramand@returntags following PSR-5/phpDoc conventions. - Multi-line support — multiple
@commentlines each become a separate line in the description block. A blank*separator is automatically inserted between the description and@param/@returntags. - Interface propagation — descriptions appear in both the generated class method and the interface method docblock.
- Coexists with
@deprecated— description appears first, then the@deprecatedtag. - Works with all return types:
:one,:opt,:many,:many-paginated,:paginated,:exec,:batch,:cursor,:transaction. - Queries without
@commentare completely unaffected — no change in generated output. - 31 new tests in
tests/CommentAnnotationTest.php.
- Fixed
Undefined variable $baseDir— caused a fatalTypeErroron every generation run, even whenctes:was not configured at all.$baseDiris now derived fromdirname(realpath($configPath)). - Fixed fatal
Unknown named parameter $returnType— triggered whenever a query actually used@use. The internalQueryDefinitionreconstruction referenced a non-existent property name and omitted several required ones. All properties are now listed correctly. - Both bugs were undetected by the 2.13.0 unit test suite because it exercised
CteRegistryandQueryDefinitiondirectly rather than through the CLI entrypoint. 6 new end-to-end tests intests/CliCteRegressionTest.phpnow invokebin/sqlc-phpas a real subprocess against temporary project directories. - No changes to the public API or CTE syntax — pure bugfix release.
ctes: files and @use annotation feature- New
@cte nameblock format — declare named CTEs in dedicated.sqlfiles. Each file can contain multiple@cteblocks. Files are pure SQL, no YAML or PHP. - New
ctes:key insqlc.yaml— at root level (global, available to all targets) and/or per-target. Accepts a single path or a list. Paths resolved relative tosqlc.yaml. - New
@use cte_nameannotation — references one or more shared CTEs in a query. Comma-separated and multi-line forms both supported. sqlc-php injects theWITHclause before analysis — transparent to the Analyzer, Resolver, and all Generators. - Duplicate detection — CTE names must be unique across all loaded files; redefinition throws a clear error with both source file paths.
- Guard against mixing — a query using
@usethat also declares an inlineWITHthrows a clear error. - CLI logs loaded CTE names per target when CTEs are registered.
- 31 new tests in
tests/SharedCteTest.php.
@json:one / @json:many cardinality variants feature- New
@json:one alias ClassName— forJSON_OBJECTcolumns that hold a single embedded object. Generates a typedClassName $aliasproperty and afromRowcast usingClassName::fromRow(json_decode(...))withoutarray_map. - New
@json:many alias ClassName— explicit form of the existing default; identical output to bare@json. - Bare
@jsonunchanged — defaults to:manyfor full backward compatibility. - Mixed cardinality on the same query is supported:
@json:one address Address+@json:many orders Ordereach produce independent properties and DTO files. - 14 new tests in
tests/JsonCardinalityTest.php.
table.column in Filter for JOIN queries bugfix- Fixed MySQL
Column is ambiguouserror when using@searchableon queries that JOIN multiple tables sharing a column name (e.g.reserve_idinreserves,products, andpayments). - Root cause:
CriteriaGeneratorused the result alias as the bare column name insideFilter::eq('reserve_id', ...). MySQL cannot resolve it inWHEREwhen multiple joined tables have the same column. - Fix: when a
ResolvedColumnhas bothtableNameandcolumnNameset, the generatedFilterusestable.column— e.g.Filter::eq('reserves.reserve_id', $value)andFilter::eq('products.reserve_id', $value). - Unaffected: method names,
orderBy*(),COLUMN_*constants, andallowedColumnsall continue to use the alias. Expression/aggregate columns fall back to alias as before. - 13 new tests in
tests/CriteriaFilterColumnTest.php.
- New
@json alias ClassNameannotation — declares that aJSON_ARRAYAGGresult column should be deserialized into aClassName[]typed array instead of a plain PHParray. The referenced class is always generated as a standalone readonly DTO, inferred from the matching schema table (City→cities,Country→countries, etc.). - Generated parent DTO uses
array_map(fn(array $r) => City::fromRow($r), json_decode(...))for the cast, and emits a/** @var City[] */docblock on the property for full IDE type inference. - JSON DTO placement follows
scoped_dtos:— whentrue, the DTO and its extension scaffold live inDTOs/{Group}/{Method}/; whenfalse, they are placed flat in the DTOs directory. - Extension trait support — when
extensions:is declared inout:, aCityExtensionscaffold is generated alongside the DTO (write-once, never overwritten) with@propertytags for all schema columns and a@mixinpointing toCity. The parent DTO extension also lists the@jsoncolumn as@property array $cities. - Error on unknown table — generation fails with a clear
RuntimeExceptionif no schema table matches the class name (direct, lowercase, or plural heuristic). - Multiple
@jsonannotations per query are supported — each produces its own DTO file and extension scaffold. - 26 new tests in
tests/JsonAnnotationTest.php.
@countednow works with:cursor— generates a companion{name}Count(): intmethod alongside the cursor pagination method. The count uses the original user SQL (without cursor WHERE injection) wrapped in a subquery, so it correctly handles JOINs,@optionalconditions, and nested queries.- The
Count()method signature mirrors the cursor method's user params (userId,status, etc.) but excludes$after,$before, and$limit— it always counts the full filtered dataset. - Intended usage: call once on the first page load, store total in UI state, reuse across page navigations.
- 6 new tests in
tests/CursorPaginationTest.php.
Criteria::fromArray(array $filters): static— new static method on the baseCriteriaclass, inherited by all generated*Criteriaclasses. Builds a Criteria instance from positional filter tuples:[column, operator, value].- Supports all 14 operators:
= != > < >= <=(and word aliaseseq neq gt lt gte lte),like starts_with ends_with in not_in is_null is_not_null between. - Column names are validated against
$allowedColumns— same list used byorderBy(). An unknown column throwsInvalidArgumentExceptionbefore any SQL is built. - Special cases:
is_null/is_not_nullaccept 2-element tuples (no value);in/not_inexpect an array value;betweenexpects[from, to]. - Returns the same immutable Criteria instance — chainable with the fluent API.
- 33 new tests in
tests/CriteriaFromArrayTest.php.
@searchablenow works with:cursor— generates a typed Criteria class withwhere*()filter methods for dynamic WHERE conditions on cursor-paginated queries.orderBy*()methods are omitted from Criteria generated for:cursorqueries. The ORDER BY is fixed by@cursorand must not change between pages — changing it would invalidate cursor tokens. The class docblock explains this explicitly.- Bugfix:
toSql()→toFilterClause()— the internalrenderSearchableCursorMethodwas calling a non-existent$criteria->toSql(). Fixed to use the correcttoFilterClause(bool $appendMode)method. This bug was hidden because the analyzer previously rejected the combination. - 7 new tests in
tests/CursorPaginationTest.php.
@countednow works with:cursor— generates a companion{name}Count(): intmethod alongside the cursor pagination method. The count uses the original user SQL (without cursor WHERE injection) wrapped in a subquery, so it correctly handles JOINs,@optionalconditions, and nested queries.- Scoped Criteria fix — when
scoped_dtos: true,@searchableCriteria classes are now placed inCriterias/{QueryName}/ClassName.phpmirroring the DTO directory structure. Previously they were always written flat. The namespace is also scoped:App\Database\Criterias\GetByFilter\FacturantelogCriteria. - The
Count()method signature mirrors the cursor method's user params but excludes$after,$before, and$limit. Call once on the first page load; the total does not change as you paginate. - 6 new tests in
tests/CursorPaginationTest.php, 3 new tests intests/ScopedDtosTest.php.
- Backward pagination via
$before— all cursor methods now accept?string $before = nullalongside$after. Pass$beforewith aprevCursortoken to navigate to the previous page. CursorResult::$hasPrev— bool flag indicating whether a previous page exists. Alwaysfalseon the first page ($afterand$beforeboth null).CursorResult::$prevCursor— opaque token built from the cursor-column values of the first row of the current page. Pass as$beforeto retrieve the previous page.nullon the first page.- Backward query uses an inverted
ORDER BYand comparison operator (>instead of<for DESC columns), thenarray_reverse()to restore the original order in the result. $afterand$beforeare mutually exclusive — passing both throwsInvalidArgumentException.- 7 new tests in
tests/CursorPaginationTest.php.
:cursorreturn type — keyset (cursor) pagination. O(1) regardless of page depth. Requires@cursor col [DIR], col [DIR]to declare the ordering columns.CursorResult<T>— new runtime class with$items,$hasMore, and$nextCursor(opaque token). IncludesdecodeCursor()andencodeCursor()static helpers.- Works with required params,
@optionalparams, and@searchablecriteria. When combined with@searchable,orderBy()is suppressed on the Criteria class — the ORDER BY is fixed by@cursor. - Cursor SQL is injected automatically before
ORDER BY; fetcheslimit+1rows to detecthasMorewithout a COUNT query. - Analyzer validates:
@cursorrequires:cursorreturn type; UNION queries rejected;@countedrejected. - 31 new tests in
tests/CursorPaginationTest.php.
- Extension traits for enums — the
extensions:feature now also covers backed enums. For every generated enum, sqlc-php generates a write-once trait scaffold inExtensions/Enums/. - Enum scaffolds document the available cases (
FacturantelogSupplier::Afip → 'afip') and carry a@mixin EnumClassfor IDE type inference, but deliberately omit@propertytags — PHP raises a fatal error if a trait used by an enum declares instance properties. - The warning "Traits used by enums MUST NOT declare instance properties" is included in every scaffold so developers who are unfamiliar with the restriction see it immediately.
OutputConfiggainsextensions_enumssubtype →Extensions/Enums/.- 13 new tests in
tests/ExtensionGeneratorTest.php.
- Extension trait scaffolding — add
extensions:toout:to generate write-once trait scaffolds for every model and DTO. sqlc-php writes the scaffold once and never overwrites it; you add domain methods while sqlc-php regenerates thereadonly classside freely. - Tooling support via
@mixin+@property— each scaffold carries a@mixin \Full\Class\Nametag (PhpStorm, Intelephense, Psalm resolve$thisas the full host class — complete autocomplete) and one@property type $nameper host property (PHPStan strict mode, human-readable documentation). Both tags are generated automatically; no manual annotations needed to start. - Structure:
Extensions/Models/for table models;Extensions/DTOs/Group/Method/for query DTOs, mirroringscoped_dtos: truepaths exactly. - Embed DTOs generate their own extension with prefix-stripped property names (
$emailnotuser__email). - Extensions are fully excluded from
--verifyand--diff. - 29 tests in
tests/ExtensionGeneratorTest.php.
- Fix A —
renderPaginateCore(): The duplicated COUNT+SELECT body shared between:paginatedand:paginated + @searchableis now extracted into a singlerenderPaginateCore()helper. Both entry points delegate to it with their specific SQL expressions and binding blocks. - Fix B —
InterfaceGeneratorstrategy dispatch: The monolithicrenderMethodSignature()with 7 nestedif/elseifbranches is replaced by amatch()dispatch table routing to one dedicated renderer per return-type family (renderOneSignature,renderManyPaginatedSignature,renderPaginatedSignature, etc.). Adding a new return type now means adding one method — the router never changes. - Fix C —
renderBindings(string $stmtVar = '$stmt'): The root cause of the$stmt undefinedbug.renderBindings()now accepts an explicit statement variable name. The:paginatedmethods callrenderBindings($query, '$__countStmt')andrenderBindings($query, '$__stmt')directly — eliminating the fragilestr_replace('$stmt->', '$__countStmt->')workaround entirely. - 23 new tests in
tests/TechDebtRefactorTest.php.
- Embed collision detection — when two queries declare
@embedwith the same class name but different columns, generation now aborts with a clear error listing the conflicting queries and three resolution options. scoped_dtos: true— new target-level config option. When enabled, each query method's DTOs, embed classes, and@searchableCriteria are placed in a subdirectory named after the method (DTOs/GetBillingDetails/BillingReserve.php,Criterias/GetByFilter/BillingCriteria.php). The namespace is adjusted accordingly, making collisions structurally impossible.- Backward compatible:
scoped_dtos: falseby default. Existing projects work unchanged until a collision is detected. - 16 new tests in
tests/ScopedDtosTest.php.
Configuration
targets:
- namespace: "App\\Database"
scoped_dtos: true # ← isolates DTOs and Criteria per query method
out:
queries: app/Database/Repositories
dtos: app/Database/DTOs
criterias: app/Database/Criterias
# DTOs/GetBillingDetails/BillingDetails.php (namespace: App\Database\DTOs\GetBillingDetails)
# DTOs/GetBillingDetails/BillingReserve.php (namespace: App\Database\DTOs\GetBillingDetails)
# DTOs/GetBillingWithDate/BillingWithDate.php (namespace: App\Database\DTOs\GetBillingWithDate)
# Criterias/GetByFilter/BillingCriteria.php (namespace: App\Database\Criterias\GetByFilter)
# Criterias/ListBySupplier/BillingCriteria.php (namespace: App\Database\Criterias\ListBySupplier)
Collision error (scoped_dtos: false)
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 to isolate DTOs per query method.
2. Use distinct class names:
-- @embed BillingReserveDetails reserve__ (in getBillingDetails)
-- @embed BillingReserveAlt reserve__ (in getBillingWithDate)
3. Use the same columns in both queries if the same class is intended.
- Bug fix:
table.*with@embedand@dtonow generates the correct return type. Previously, writingreserve_billing.*alongside JOIN columns with__prefixes (e.g.reserve.id as reserve__id) causeddetectDirectModelto see multiple table names and fall back to an unnamed DTO, ignoring the@dtoannotation. detectDirectModelimprovement: columns with__in their alias are now excluded from the single-table uniqueness check. These are embedded object fields (from@embed) and belong to joined tables by design — they should not affect model detection for the primary table.@embedstill forces DTO mode (correct behaviour): even when all primary columns come from a single table,@embedmeans the result has nested embedded properties that the plain model doesn't have. The@dtoclass name is used as the return type.- 13 new tests in
tests/TableWildcardEmbedTest.php.
Fix summary
-- Before: return type was GetDetailsRow (wrong)
-- After: return type is ReserveBilling (correct)
-- @name GetDetails
-- @class ReserveBilling
-- @dto ReserveBilling
-- @embed ReserveBillingReserve reserve__
-- @returns :one
SELECT reserve_billing.*, -- table.* expands all billing columns
reserve.id as reserve__id, -- __ prefix = @embed column, ignored by model detector
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
Criteria::orGroup(callable $callback): static— new method for OR conditions. The callback receives a fresh instance of the same Criteria subclass; its filters are combined with AND and OR-ed with all other groups. Fully immutable and backward compatible — criteria withoutorGroup()generate identical SQL to before.- UNION / UNION ALL queries — natively supported. Column types are resolved from the first SELECT branch. Parameters from all branches are collected.
@searchable,@partial, and@returningare rejected with a clear error on UNION queries. ColumnResolver::extractFirstUnionBranch()— new method that correctly handles nested subquery UNIONs (respects paren depth).- 35 new tests in
tests/OrGroupUnionTest.php.
- Fix A —
renderBindings($stmtVar):renderBindings()now accepts an explicit statement variable name.renderPaginateMethod()calls it with'$__countStmt'and'$__stmt'directly — nostr_replace()hacks. Fixes Undefined variable $stmt when@optionalparams are used with:paginated. - Fix B —
renderPaginateCore()shared method: bothrenderPaginateMethod()andrenderSearchablePaginateMethod()delegate to a singlerenderPaginateCore(). The COUNT+PAGE body exists exactly once — any future change happens in one place. - Fix C —
InterfaceGeneratorstrategy dispatch:renderMethodSignature()now uses amatch()dispatch table. Each return-type family has a dedicated private renderer. Adding a new return type requires adding one method and one match arm — the router never changes. - 37 tests updated and expanded in
tests/TechDebtRefactorTest.php, covering structural invariants for each fix.
@returns :paginated— new return type (native, like:manyor:one). Runs aCOUNT(*)and a paginatedSELECTin one call, returning aPaginatedResultobject withitems,total,pages,hasMore, and navigation helpers. Defaults tolimit = 10. Can be combined with@searchable. Cannot be combined with@counted.@returning— after executing an INSERT, fetches and returns the newly created row by its primary key (lastInsertId()). Only valid on:oneINSERT queries. RejectsON DUPLICATE KEY UPDATEwith a clear error.SchemaCatalog::primaryKey()— new method detecting the PK column fromPRIMARY KEY,AUTO_INCREMENT, or column namedid.ColumnDefinition::$isPrimaryKey— new field parsed from inlinePRIMARY KEYdeclarations inCREATE TABLE.SqlcPhp\Query\PaginatedResult— new runtime readonly class with navigation helper methods.- 53 new tests in
tests/PaginateReturningTest.php.
QueryObject::toDebugBindings(): list<mixed>— new method returning a flat indexed array of bound values, compatible with Laravel'sQueryExecutedconstructor and Debugbar'sQueryCollector::addQuery().- Filters internal implementation details:
_chksuffixed params generated by@optionalqueries — prevents the[,1]display bug when bindings are passed directly to Debugbar.:limitand:offsetfrom:many-paginatedqueries.
- Two integration approaches documented: Option A (
toDebugSql()+ empty bindings) and Option B (toString()+toDebugBindings()). - 17 new tests in
tests/DebugBindingsTest.php.
QueryObject::$durationMs— every generated method now wraps$stmt->execute()withhrtime(true)and stores the elapsed time in milliseconds as afloat.QueryObject::withDuration(float $ms): self— immutable named constructor that returns a copy of the object with the measured duration set.- PSR-3 log format updated — log messages now include the duration:
"listActiveUsers [4.217ms]: SELECT * FROM users WHERE ..." :batchtiming — measures the entire transaction (all rows + commit) as a single duration, not per-iteration.- Uses
hrtime(true)(monotonic, nanosecond precision) divided by1_000_000for milliseconds. Immune to NTP and clock adjustments. - 21 new tests in
tests/DurationTest.php.
- Constructor updated — every generated Query class now accepts two optional parameters:
?LoggerInterface $logger = nulland?Closure $afterQuery = null. Fully backward compatible — existingnew UserRepository($pdo)calls work unchanged. - PSR-3 logger — when provided, every executed method calls
$logger->debug(queryName + SQL, values). Works with Monolog, Laravel Log, Symfony Logger, and any PSR-3 implementation. Queries appear in Laravel Telescope under the Logs tab. - afterQuery hook — a
Closurecalled after every query with theQueryObjectas its argument. Use for Laravel Debugbar (Debugbar::addMessage), OpenTelemetry spans, custom metrics, or per-request query collection. psr/log: ^1.0 || ^2.0 || ^3.0added as a composer dependency.- 22 new tests in
tests/LoggerHookTest.php.
lastQuery(): ?QueryObject— every generated Query class now records the SQL and bound parameters of the most recently executed method. Call immediately after any query method to inspect what was sent to the database.QueryObject— new immutable value object inSqlcPhp\Query\QueryObject. Methods:toString()(safe SQL),toDebugSql()(interpolated, debug-only),bindings(),values(),cacheKey(),paramCount().- Criteria::getBindings() — new method exposing filter bindings as an array;
bindAll()now delegates to it. Enables@searchablemethods to correctly populatelastQuerywith dynamic criteria bindings. - Not in interface —
lastQuery()is an infrastructure detail and is deliberately excluded from generated*Interface.phpfiles. - 43 new tests in
tests/LastQueryTest.php.
out:now accepts a YAML map — each file type (queries,models,dtos,enums,interfaces,criterias) can have its own output directory and automatically derived namespace.- Namespace derivation —
namespace + '\' + last path segment. No extra config required. - Automatic
usestatements — when types live in different namespaces, the necessaryuseimports are injected into generated files automatically. - Backward compatible —
out: generated(string form) continues to work exactly as before. - Error on missing type — if a file type is needed but not declared in the map, generation stops with a clear error message before writing any files.
- 23 new tests in
tests/OutputConfigTest.php.
@partial— 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 — no PHP conditionals needed.- Param ordering is automatic — required WHERE params always come first in the signature; optional SET params go last, regardless of their order in the SQL.
- Works alongside
@optional— use@partialfor optional SET fields and@optionalfor optional WHERE filters on the same query. - Only valid on
:execUPDATE queries. - 23 new tests in
tests/PartialTest.php.
@searchable— typed Criteria parameter for:manyand:many-paginated. Dynamic WHERE and ORDER BY at runtime.- Generated
{Group}Criteria— typed per-column methods:whereActiveIn(int ...$values),whereEmailLike(string),whereCreatedAtBetween(DateTimeImmutable, DateTimeImmutable), etc. @searchable+@counted— count method accepts same Criteria for consistent totals.- SQL injection safe — ORDER BY validated against whitelist; IN/NOT_IN use per-element placeholders.
- 71 new tests in
tests/SearchableTest.php.
- YAML parsing migrated from hand-written parser to
symfony/yaml. Eliminates a persistent source of parsing bugs. symfony/yaml: ^6.0 || ^7.0 || ^8.0added tocomposer.json.src/Config/YamlParser.phpretained as fallback shim.
--generate-schema— extracts schema from a live MySQL/MariaDB database.AUTO_INCREMENT=Nstripped.database:config block —dsn,username,password,exclude_tables,include_tables. Supports${ENV_VAR}.- YAML parser extended to support nested maps inside list items.
@class— canonical replacement for@group. No warning emitted.@groupdeprecated — still works, emits a warning.class_suffix— global or per-target config option.Repository→UserRepository,UserRepositoryInterface.
:many-paginated—$limitis now?int $limit = null. Calling without arguments returns all rows.- Two code paths generated. Cache-safe: distinct keys
_all/_pagewithprepared_statement_cache: true.
@counted— companion{name}Count(): intmethod for:many-paginated. Wraps SQL inSELECT COUNT(*) FROM (...).- Fix:
$limit/$offsetno longer appear in user-facing method signatures.
--watch— file polling loop, regenerates on change.--interval=N— custom poll interval in ms.Watcherclass tracks files byfilemtime.
:batch— run same query N times in one transaction.:transaction— orchestrate multiple methods in one transaction via@calls.prepared_statement_cache: true— cachePDOStatementobjects per method.@dto— override DTO class name.@column— rename result column.
@embed— nested readonly objects for JOIN results.@nillable— force a result column to nullable.@optional+ JOIN — allowed when param is in WHERE (not ON). PDO HY093 fix via:param_chk.
- Numeric literals (
0,42,3.14) resolve toint/float. - Arithmetic expressions resolve from left operand type.
IF()resolves from true branch; falls back to false branch when true isNULL.