sqlc-php v2.13.1

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.

PHP 8.3+ PDO Typed No ORM

How it works

schema.sql + queries.sql + sqlc.yaml
php vendor/bin/sqlc-php sqlc.yaml
User.php + UserQuery.php + UserQueryInterface.php

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

RequirementVersion
PHP≥ 8.3
PDO extensionEnabled (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

KeyLevelDefaultDescription
schemaglobalSQL files with CREATE TABLE statements
engineglobal / targetmysqlDatabase engine for type mapping
languageglobal / targetenglishLanguage for pluralization (model class names)
class_suffixglobal / targetQuerySuffix appended to generated class names
type_overridesglobal / target[]Custom SQL → PHP type mappings
databaseglobal / targetnullConnection config for --generate-schema
virtual_tablesglobal[]Tables not in schema files (views, CTEs)
includesglobal[]Split config across multiple YAML files
targets[].namespacetargetPHP namespace base for generated files
targets[].outtargetOutput directory (string) or per-type map — see below
targets[].queriestargetSQL files with annotated queries
targets[].generate_interfacestargettrueGenerate *Interface.php files
targets[].prepared_statement_cachetargetfalseCache 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
KeyContains
queriesQuery / Repository classes (UserRepository.php)
modelsModel classes (User.php) — readonly, generated from schema
dtosResult DTOs (GetUserWithRoleRow.php) and Embed DTOs from @embed
enumsBacked enum classes generated from MySQL ENUM columns
interfacesQuery interfaces (UserRepositoryInterface.php)
criteriasCriteria 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 typePHP typefromRow cast
INT, BIGINT, SMALLINT, TINYINTint(int)
FLOAT, DOUBLE, DECIMAL, NUMERICfloat(float)
VARCHAR, CHAR, TEXT, LONGTEXTstring
DATE, DATETIME, TIMESTAMP\DateTimeImmutablenew \DateTimeImmutable()
JSONarrayjson_decode(, true)
ENUM('a','b')EnumClassEnumClass::from()
NULL columns?typenull 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
MySQL / MariaDB only in v2.6.0+. Uses 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.

AnnotationRequiredDescription
@nameYesPHP method name (camelCase)
@returnsYesReturn type — see Return Types section
@classNoGroup name → generated class prefix (e.g. UserUserQuery). Inferred from table name when absent.
@groupNoDeprecated — use @class instead. Still works, emits a warning.
@optionalNoMake a WHERE param skippable when null
@dtoNoOverride the auto-generated result DTO class name
@columnNoRename a result column without SQL AS
@embedNoGroup prefixed columns into a nested readonly object
@jsonNoDeserialize a JSON_ARRAYAGG column into a typed ClassName[] array — generates a standalone readonly DTO inferred from the schema table. v2.12.3
@nillableNoForce a result column to ?type (nullable)
@typeNoForce an explicit PHP type on a result column — overrides the inferred type. Essential for UNION queries and constant expressions. v2.9.6
@countedNoGenerate a companion {name}Count(): int method. Valid with :many-paginated and :cursor. For :cursor, counts total rows matching user filters (independent of cursor position).
@cursorNoDeclare cursor columns for keyset pagination — required with :cursor. Syntax: col1 [ASC|DESC], col2 [ASC|DESC]. v2.11.2
@searchableNoGenerate 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
@deprecatedNoEmit @deprecated in the method docblock
@paramNoExplicit type for a named parameter
@callsNoMethods to call in sequence (used with :transaction)
@partialNoMark SET params in an UPDATE as optional — passing null leaves the column unchanged. Only valid on :exec UPDATE queries.
@returningNoAfter an INSERT, fetch and return the newly created row by its primary key. Only valid on :one INSERT queries without ON DUPLICATE KEY.
@cteDeclares a named reusable CTE in a .sql file — not a query annotation, but a block header in CTE files. v2.13.0
@useNoInject 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,
    ) {}
}
The prefix can have any number of trailing underscores: 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:

SyntaxCardinalityUse caseGenerated type
@json alias ClassNamemany (default)JSON_ARRAYAGG(...)ClassName[]
@json:many alias ClassNamemany (explicit)JSON_ARRAYAGG(...)ClassName[]
@json:one alias ClassNameone v2.12.5JSON_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
Key behaviours
  • The DTO is always generated — never reused from an existing model class.
  • Placement respects scoped_dtos: — when true, the DTO lives in DTOs/{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 → tries address, addresss, addresses. Fails with a clear error if no match.
  • Bare @json defaults 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

phpTypeNullable variantExample
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
Precedence over @nillable. When both @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.
Works on any query, not only UNION. @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 loadAlways 2 (COUNT + SELECT)Caller controls — 1 or 2
Return typePaginatedResult — items, total, pages, hasMore pre-computedarray + separate int
BoilerplateNone — one call does everythingCaller must call both methods and compute pages/hasMore
COUNT on every pageYes, alwaysNo — caller decides (e.g. cache it, skip on page 2+)
Infinite scroll / cursorCounts every request unnecessarily✅ Count once on page 1, skip on subsequent pages
Very large tablesCOUNT(*) 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);
Rule of thumb. Start with :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
Must match ORDER BY. The columns in @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.
Include a unique column. Always end the cursor column list with a unique column (typically 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 typeGenerated methods
INT / TINYINTEq, Neq, Gt, Lt, Gte, Lte, In, NotIn — + IsNull/IsNotNull if nullable
VARCHAR / TEXTEq, Neq, Like, StartsWith, EndsWith, In, NotIn — + IsNull/IsNotNull if nullable
DECIMAL / FLOATEq, Neq, Gt, Lt, Gte, Lte — + IsNull/IsNotNull if nullable
DATE / DATETIMEEq, Neq, Gt, Lt, Gte, Lte, Between — + IsNull/IsNotNull if nullable
TINYINT (bool)Eq only
All typesorderBy{Column}(string $direction = 'ASC')
ORDER BY column names are validated against a generated whitelist (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;
}
Rules
  • Each @comment line becomes one line in the docblock description.
  • A blank * separator is automatically inserted between the description block and the first @param or @return tag.
  • @comment coexists with @deprecated — description appears first, then @deprecated.
  • Empty @comment lines (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 @comment are 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 / MethodTypeDescription
$r->itemsarrayRows for the current page (ModelClass[])
$r->totalintTotal rows matching the query (all pages)
$r->limitintRows per page as requested
$r->offsetintRows skipped as requested
$r->pagesintceil(total / limit) — 0 when total is 0
$r->hasMoreboolTrue when there are rows after this page
$r->currentPage()intCurrent page number (1-based)
$r->isFirstPage()boolTrue when offset is 0
$r->isLastPage()boolTrue when there is no next page
$r->nextOffset()?intOffset for the next page, null if last
$r->previousOffset()?intOffset for the previous page, null if first
Two queries per call. The generated method always runs a 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.

MySQL only — 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
File format rules
  • Each block starts with -- @cte name on its own line and ends at the next -- @cte or EOF.
  • Trailing semicolons are stripped automatically — only the SELECT body is used.
  • Comments and blank lines inside a body are preserved.
  • Content before the first @cte annotation (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;
Rules & constraints
  • A query using @use must not also declare an inline WITH clause — mixing the two is a hard error with a clear message.
  • Duplicate CTE names in a single @use list are silently deduplicated.
  • CTEs are injected in the order they appear in the @use annotation — relevant when one CTE references another.
  • Global ctes: (root of sqlc.yaml) are available to every target. Per-target ctes: add on top.
  • CTEs are transparent to the Analyzer: all annotations (@searchable, @json, @embed, @optional, @partial, @cursor) work normally on queries using @use.
  • The --verify and --diff flags 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 inWHERE clauseSET clause
SQL rewritingYes — rewrites col = :p to (:p_chk IS NULL OR col = :p)No — you write the COALESCE yourself
Use caseSkip 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 combineYes — @partial + @optional on same queryYes
@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

TypePHP returnBehaviour
:manyModelClass[]Array of rows — empty array if no rows
:many-paginatedModelClass[]Array with optional ?int $limit / int $offset. null limit returns all rows.
:paginatedPaginatedResult<ModelClass>One call returns items + total + pages + navigation metadata. Executes COUNT + SELECT internally. limit defaults to 10.
:cursorCursorResult<ModelClass>Keyset pagination — O(1) at any depth. No COUNT query. Requires @cursor. v2.11.2
:oneModelClassSingle row — throws RuntimeException if not found
:optModelClass|nullSingle row — returns null if not found
:execvoidINSERT / UPDATE / DELETE — no return value
:batchintSame query run N times in a transaction — returns total rows affected
:transactionvoidCalls 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 page2 (COUNT + SELECT)1 (SELECT only)
Performance at depthO(offset) — slows on deep pagesO(1) — always fast
Total count / page count✅ always available❌ not computed
Jump to arbitrary page✅ via offset❌ walk forward only
Consistent readsInserts may cause skips✅ cursor anchors to data
Ideal forAdmin tables, numbered pages, reportsFeeds, 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]
FlagDescription
-h, --helpShow help message and exit
-v, --versionPrint 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

ExpressionResolved 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]);
Passing an empty array to an 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

generated/
  User.php ← readonly model, fromRow() factory
  UserQuery.php ← one method per query (or UserRepository.php with class_suffix)
  UserQueryInterface.php ← interface for DI
  OrderStatus.php ← backed enum (MySQL ENUM columns)
  GetUserWithRoleRow.php ← result DTO (JOIN queries)
  Role.php ← embedded value object (@embed)
  UserCriteria.php ← dynamic filter builder (@searchable)

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()],
            );
        }
    },
);
What is measured: only $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]
In Laravel, 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 / PropertyReturnsDescription
toString()stringSQL with named PDO placeholders — safe to log
(string) $qstringSame as toString()
toDebugSql()stringSQL 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()arrayAll bound parameters with PDO type constants — internal format
values()arrayNamed bound values (keyed by placeholder) — use as PSR-3 log context
cacheKey()stringStable md5 key — unique per SQL + values
paramCount()intNumber of bound parameters
$q->queryNamestringName of the method that produced this query
$q->durationMsfloatExecute time in milliseconds. 0.0 if not yet measured.
$q->isBatchbooltrue for :batch queries
$q->batchCountintRows processed (batch only)
Never pass 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

src/
  Analyzer/ ← QueryAnalyzer — resolves types, validates annotations
  Catalog/ ← SchemaCatalog — table/column registry
  Config/ ← Config, Target, DatabaseConfig, YamlParser
  Criteria/ ← Criteria, Filter, FilterOperator (runtime, @searchable)
  Generator/ ← QueryGenerator, ModelGenerator, ResultDtoGenerator, CriteriaGenerator, InterfaceGenerator, EnumGenerator
  Parser/ ← SchemaParser, QueryParser, QueryDefinition
  Resolver/ ← ColumnResolver, ParamResolver, ExpressionTypeResolver
  Rewriter/ ← SqlRewriter (@optional param rewriting)
  SchemaExtractor/ ← MySQLSchemaExtractor (--generate-schema)
  TypeMapper/ ← MySQLTypeMapper, TypeMapperInterface
  Version.php ← Version::VERSION = '2.12.0'
  Watcher.php ← File watcher for --watch mode
bin/
  sqlc-php ← CLI entry point

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:

TagWhat it doesUnderstood 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 tags may go stale. The scaffold is written once and never regenerated. If you add a column to the schema later, the @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. Extension scaffold files are completely excluded from --verify and --diff. sqlc-php never compares or reports changes in your extension files — they are invisible to the tooling.
New columns. When a new column is added to your schema and you regenerate, the host model is updated automatically — but the extension trait is not (it is write-once). The @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
Call Count() once, not on every page. The total doesn't change as you paginate (assuming no inserts/deletes between pages). Store the total in your UI state and reuse it across page navigations — avoid calling Count() on every $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]);
$after and $before are mutually exclusive. Passing both throws 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:

  1. Forward ($after): injects AND (created_at, id) < (:cursor) before ORDER BY created_at DESC, id DESC. The _chk IS NULL guard makes the condition a no-op on the first page.
  2. Backward ($before): injects AND (created_at, id) > (:cursor) before ORDER BY created_at ASC, id ASC (all directions inverted), then calls array_reverse() to restore the original DESC order.
  3. Both directions fetch $limit + 1 rows to detect whether another page exists, without a COUNT query.
  4. nextCursor is built from the last row's cursor column values. prevCursor is built from the first row.
Do not include LIMIT in your SQL. The generator manages LIMIT internally (as LIMIT :__limit = limit + 1). If you include LIMIT :limit, it will be stripped automatically.
Include cursor columns in SELECT. The cursor columns (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)MeaningValue
= eqEqualscalar
!= <> neqNot equalscalar
> gtGreater thanscalar
< ltLess thanscalar
>= gteGreater or equalscalar
<= lteLess or equalscalar
likeLIKE %value%string
starts_withLIKE value%string
ends_withLIKE %valuestring
inIN (...)array
not_inNOT IN (...)array
is_null nullIS NULLnone — 2-element tuple
is_not_null not_nullIS NOT NULLnone — 2-element tuple
betweenBETWEEN 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]]);
Value types. 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.
Filters are combined with AND. All tuples in the array are joined with 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

v2.14.1 — Hotfix: @comment lost when placed before or around @name hotfix
  • Fixed: @comment before @name was silently dropped. The block splitter used @name as the start marker, so any annotations before it were discarded. The carry-forward logic now prepends them to the next block.
  • Fixed: @comment text containing @name or @class corrupted 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.
v2.14.0 — @comment annotation — method docblock descriptions feature
  • New @comment text annotation — adds a human-readable description to the generated method docblock and its matching interface method, placed before @param and @return tags following PSR-5/phpDoc conventions.
  • Multi-line support — multiple @comment lines each become a separate line in the description block. A blank * separator is automatically inserted between the description and @param/@return tags.
  • Interface propagation — descriptions appear in both the generated class method and the interface method docblock.
  • Coexists with @deprecated — description appears first, then the @deprecated tag.
  • Works with all return types: :one, :opt, :many, :many-paginated, :paginated, :exec, :batch, :cursor, :transaction.
  • Queries without @comment are completely unaffected — no change in generated output.
  • 31 new tests in tests/CommentAnnotationTest.php.
v2.13.1 — Hotfix: CTE feature crashed in real-world CLI usage hotfix
  • Fixed Undefined variable $baseDir — caused a fatal TypeError on every generation run, even when ctes: was not configured at all. $baseDir is now derived from dirname(realpath($configPath)).
  • Fixed fatal Unknown named parameter $returnType — triggered whenever a query actually used @use. The internal QueryDefinition reconstruction 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 CteRegistry and QueryDefinition directly rather than through the CLI entrypoint. 6 new end-to-end tests in tests/CliCteRegressionTest.php now invoke bin/sqlc-php as a real subprocess against temporary project directories.
  • No changes to the public API or CTE syntax — pure bugfix release.
v2.13.0 — Shared CTEs via ctes: files and @use annotation feature
  • New @cte name block format — declare named CTEs in dedicated .sql files. Each file can contain multiple @cte blocks. Files are pure SQL, no YAML or PHP.
  • New ctes: key in sqlc.yaml — at root level (global, available to all targets) and/or per-target. Accepts a single path or a list. Paths resolved relative to sqlc.yaml.
  • New @use cte_name annotation — references one or more shared CTEs in a query. Comma-separated and multi-line forms both supported. sqlc-php injects the WITH clause 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 @use that also declares an inline WITH throws a clear error.
  • CLI logs loaded CTE names per target when CTEs are registered.
  • 31 new tests in tests/SharedCteTest.php.
v2.12.5 — @json:one / @json:many cardinality variants feature
  • New @json:one alias ClassName — for JSON_OBJECT columns that hold a single embedded object. Generates a typed ClassName $alias property and a fromRow cast using ClassName::fromRow(json_decode(...)) without array_map.
  • New @json:many alias ClassName — explicit form of the existing default; identical output to bare @json.
  • Bare @json unchanged — defaults to :many for full backward compatibility.
  • Mixed cardinality on the same query is supported: @json:one address Address + @json:many orders Order each produce independent properties and DTO files.
  • 14 new tests in tests/JsonCardinalityTest.php.
v2.12.4 — Criteria: qualified table.column in Filter for JOIN queries bugfix
  • Fixed MySQL Column is ambiguous error when using @searchable on queries that JOIN multiple tables sharing a column name (e.g. reserve_id in reserves, products, and payments).
  • Root cause: CriteriaGenerator used the result alias as the bare column name inside Filter::eq('reserve_id', ...). MySQL cannot resolve it in WHERE when multiple joined tables have the same column.
  • Fix: when a ResolvedColumn has both tableName and columnName set, the generated Filter uses table.column — e.g. Filter::eq('reserves.reserve_id', $value) and Filter::eq('products.reserve_id', $value).
  • Unaffected: method names, orderBy*(), COLUMN_* constants, and allowedColumns all continue to use the alias. Expression/aggregate columns fall back to alias as before.
  • 13 new tests in tests/CriteriaFilterColumnTest.php.
v2.12.3 — @json annotation — typed DTO arrays for JSON_ARRAYAGG columns
  • New @json alias ClassName annotation — declares that a JSON_ARRAYAGG result column should be deserialized into a ClassName[] typed array instead of a plain PHP array. The referenced class is always generated as a standalone readonly DTO, inferred from the matching schema table (Citycities, Countrycountries, 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: — when true, the DTO and its extension scaffold live in DTOs/{Group}/{Method}/; when false, they are placed flat in the DTOs directory.
  • Extension trait support — when extensions: is declared in out:, a CityExtension scaffold is generated alongside the DTO (write-once, never overwritten) with @property tags for all schema columns and a @mixin pointing to City. The parent DTO extension also lists the @json column as @property array $cities.
  • Error on unknown table — generation fails with a clear RuntimeException if no schema table matches the class name (direct, lowercase, or plural heuristic).
  • Multiple @json annotations per query are supported — each produces its own DTO file and extension scaffold.
  • 26 new tests in tests/JsonAnnotationTest.php.
v2.11.2 — @counted support for :cursor
  • @counted now works with :cursor — generates a companion {name}Count(): int method 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, @optional conditions, 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.
v2.12.0 — Criteria::fromArray()
  • Criteria::fromArray(array $filters): static — new static method on the base Criteria class, inherited by all generated *Criteria classes. Builds a Criteria instance from positional filter tuples: [column, operator, value].
  • Supports all 14 operators: = != > < >= <= (and word aliases eq 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 by orderBy(). An unknown column throws InvalidArgumentException before any SQL is built.
  • Special cases: is_null / is_not_null accept 2-element tuples (no value); in / not_in expect an array value; between expects [from, to].
  • Returns the same immutable Criteria instance — chainable with the fluent API.
  • 33 new tests in tests/CriteriaFromArrayTest.php.
v2.11.3 — @searchable support for :cursor
  • @searchable now works with :cursor — generates a typed Criteria class with where*() filter methods for dynamic WHERE conditions on cursor-paginated queries.
  • orderBy*() methods are omitted from Criteria generated for :cursor queries. The ORDER BY is fixed by @cursor and must not change between pages — changing it would invalidate cursor tokens. The class docblock explains this explicitly.
  • Bugfix: toSql()toFilterClause() — the internal renderSearchableCursorMethod was calling a non-existent $criteria->toSql(). Fixed to use the correct toFilterClause(bool $appendMode) method. This bug was hidden because the analyzer previously rejected the combination.
  • 7 new tests in tests/CursorPaginationTest.php.
v2.11.2 — @counted for :cursor + scoped Criteria fix
  • @counted now works with :cursor — generates a companion {name}Count(): int method 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, @optional conditions, and nested queries.
  • Scoped Criteria fix — when scoped_dtos: true, @searchable Criteria classes are now placed in Criterias/{QueryName}/ClassName.php mirroring 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 in tests/ScopedDtosTest.php.
v2.11.1 — Bidirectional cursor navigation
  • Backward pagination via $before — all cursor methods now accept ?string $before = null alongside $after. Pass $before with a prevCursor token to navigate to the previous page.
  • CursorResult::$hasPrev — bool flag indicating whether a previous page exists. Always false on the first page ($after and $before both null).
  • CursorResult::$prevCursor — opaque token built from the cursor-column values of the first row of the current page. Pass as $before to retrieve the previous page. null on the first page.
  • Backward query uses an inverted ORDER BY and comparison operator (> instead of < for DESC columns), then array_reverse() to restore the original order in the result.
  • $after and $before are mutually exclusive — passing both throws InvalidArgumentException.
  • 7 new tests in tests/CursorPaginationTest.php.
v2.11.0 — Cursor pagination
  • :cursor return 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). Includes decodeCursor() and encodeCursor() static helpers.
  • Works with required params, @optional params, and @searchable criteria. 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; fetches limit+1 rows to detect hasMore without a COUNT query.
  • Analyzer validates: @cursor requires :cursor return type; UNION queries rejected; @counted rejected.
  • 31 new tests in tests/CursorPaginationTest.php.
v2.10.0 — Enum extension traits
  • Extension traits for enums — the extensions: feature now also covers backed enums. For every generated enum, sqlc-php generates a write-once trait scaffold in Extensions/Enums/.
  • Enum scaffolds document the available cases (FacturantelogSupplier::Afip → 'afip') and carry a @mixin EnumClass for IDE type inference, but deliberately omit @property tags — 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.
  • OutputConfig gains extensions_enums subtype → Extensions/Enums/.
  • 13 new tests in tests/ExtensionGeneratorTest.php.
v2.9.8 — Extension traits
  • Extension trait scaffolding — add extensions: to out: 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 the readonly class side freely.
  • Tooling support via @mixin + @property — each scaffold carries a @mixin \Full\Class\Name tag (PhpStorm, Intelephense, Psalm resolve $this as the full host class — complete autocomplete) and one @property type $name per 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, mirroring scoped_dtos: true paths exactly.
  • Embed DTOs generate their own extension with prefix-stripped property names ($email not user__email).
  • Extensions are fully excluded from --verify and --diff.
  • 29 tests in tests/ExtensionGeneratorTest.php.
v2.8.5 — Technical debt refactor
  • Fix A — renderPaginateCore(): The duplicated COUNT+SELECT body shared between :paginated and :paginated + @searchable is now extracted into a single renderPaginateCore() helper. Both entry points delegate to it with their specific SQL expressions and binding blocks.
  • Fix B — InterfaceGenerator strategy dispatch: The monolithic renderMethodSignature() with 7 nested if/elseif branches is replaced by a match() 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 undefined bug. renderBindings() now accepts an explicit statement variable name. The :paginated methods call renderBindings($query, '$__countStmt') and renderBindings($query, '$__stmt') directly — eliminating the fragile str_replace('$stmt->', '$__countStmt->') workaround entirely.
  • 23 new tests in tests/TechDebtRefactorTest.php.
v2.9.2-2.9.3 — scoped_dtos & embed collision detection
  • Embed collision detection — when two queries declare @embed with 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 @searchable Criteria 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: false by 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.
v2.9.1 — table.* with @embed bugfix
  • Bug fix: table.* with @embed and @dto now generates the correct return type. Previously, writing reserve_billing.* alongside JOIN columns with __ prefixes (e.g. reserve.id as reserve__id) caused detectDirectModel to see multiple table names and fall back to an unnamed DTO, ignoring the @dto annotation.
  • detectDirectModel improvement: 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.
  • @embed still forces DTO mode (correct behaviour): even when all primary columns come from a single table, @embed means the result has nested embedded properties that the plain model doesn't have. The @dto class 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
v2.9.0 — OR groups in Criteria & UNION queries
  • 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 without orGroup() 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 @returning are 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.
v2.8.5 — Technical debt refactoring
  • Fix A — renderBindings($stmtVar): renderBindings() now accepts an explicit statement variable name. renderPaginateMethod() calls it with '$__countStmt' and '$__stmt' directly — no str_replace() hacks. Fixes Undefined variable $stmt when @optional params are used with :paginated.
  • Fix B — renderPaginateCore() shared method: both renderPaginateMethod() and renderSearchablePaginateMethod() delegate to a single renderPaginateCore(). The COUNT+PAGE body exists exactly once — any future change happens in one place.
  • Fix C — InterfaceGenerator strategy dispatch: renderMethodSignature() now uses a match() 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.
v2.8.0 — :paginated & @returning
  • @returns :paginated — new return type (native, like :many or :one). Runs a COUNT(*) and a paginated SELECT in one call, returning a PaginatedResult object with items, total, pages, hasMore, and navigation helpers. Defaults to limit = 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 :one INSERT queries. Rejects ON DUPLICATE KEY UPDATE with a clear error.
  • SchemaCatalog::primaryKey() — new method detecting the PK column from PRIMARY KEY, AUTO_INCREMENT, or column named id.
  • ColumnDefinition::$isPrimaryKey — new field parsed from inline PRIMARY KEY declarations in CREATE TABLE.
  • SqlcPhp\Query\PaginatedResult — new runtime readonly class with navigation helper methods.
  • 53 new tests in tests/PaginateReturningTest.php.
v2.7.7 — toDebugBindings() for Debugbar integration
  • QueryObject::toDebugBindings(): list<mixed> — new method returning a flat indexed array of bound values, compatible with Laravel's QueryExecuted constructor and Debugbar's QueryCollector::addQuery().
  • Filters internal implementation details:
    • _chk suffixed params generated by @optional queries — prevents the [,1] display bug when bindings are passed directly to Debugbar.
    • :limit and :offset from :many-paginated queries.
  • Two integration approaches documented: Option A (toDebugSql() + empty bindings) and Option B (toString() + toDebugBindings()).
  • 17 new tests in tests/DebugBindingsTest.php.
v2.7.6 — query execution timing (durationMs)
  • QueryObject::$durationMs — every generated method now wraps $stmt->execute() with hrtime(true) and stores the elapsed time in milliseconds as a float.
  • 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 ..."
  • :batch timing — measures the entire transaction (all rows + commit) as a single duration, not per-iteration.
  • Uses hrtime(true) (monotonic, nanosecond precision) divided by 1_000_000 for milliseconds. Immune to NTP and clock adjustments.
  • 21 new tests in tests/DurationTest.php.
v2.7.5 — PSR-3 logger & afterQuery hook
  • Constructor updated — every generated Query class now accepts two optional parameters: ?LoggerInterface $logger = null and ?Closure $afterQuery = null. Fully backward compatible — existing new 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 Closure called after every query with the QueryObject as its argument. Use for Laravel Debugbar (Debugbar::addMessage), OpenTelemetry spans, custom metrics, or per-request query collection.
  • psr/log: ^1.0 || ^2.0 || ^3.0 added as a composer dependency.
  • 22 new tests in tests/LoggerHookTest.php.
v2.7.4 — lastQuery() inspection
  • 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 in SqlcPhp\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 @searchable methods to correctly populate lastQuery with dynamic criteria bindings.
  • Not in interfacelastQuery() is an infrastructure detail and is deliberately excluded from generated *Interface.php files.
  • 43 new tests in tests/LastQueryTest.php.
v2.7.3 — out: map form (per-type directories)
  • 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 derivationnamespace + '\' + last path segment. No extra config required.
  • Automatic use statements — when types live in different namespaces, the necessary use imports are injected into generated files automatically.
  • Backward compatibleout: 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.
v2.7.1 — @partial (PATCH/UPDATE)
  • @partial — marks an UPDATE query as a partial update. Parameters inside COALESCE(:param, column) in the SET clause become optional (?type $param = null). Passing null leaves 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 @partial for optional SET fields and @optional for optional WHERE filters on the same query.
  • Only valid on :exec UPDATE queries.
  • 23 new tests in tests/PartialTest.php.
v2.7.0 — @searchable dynamic filters
  • @searchable — typed Criteria parameter for :many and :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.
v2.6.2 — symfony/yaml migration
  • YAML parsing migrated from hand-written parser to symfony/yaml. Eliminates a persistent source of parsing bugs.
  • symfony/yaml: ^6.0 || ^7.0 || ^8.0 added to composer.json.
  • src/Config/YamlParser.php retained as fallback shim.
v2.6.0 — --generate-schema
  • --generate-schema — extracts schema from a live MySQL/MariaDB database. AUTO_INCREMENT=N stripped.
  • database: config blockdsn, username, password, exclude_tables, include_tables. Supports ${ENV_VAR}.
  • YAML parser extended to support nested maps inside list items.
v2.5.3 — @class and class_suffix
  • @class — canonical replacement for @group. No warning emitted.
  • @group deprecated — still works, emits a warning.
  • class_suffix — global or per-target config option. RepositoryUserRepository, UserRepositoryInterface.
v2.5.2 — optional pagination limit
  • :many-paginated$limit is now ?int $limit = null. Calling without arguments returns all rows.
  • Two code paths generated. Cache-safe: distinct keys _all / _page with prepared_statement_cache: true.
v2.5.0 — @counted
  • @counted — companion {name}Count(): int method for :many-paginated. Wraps SQL in SELECT COUNT(*) FROM (...).
  • Fix: $limit/$offset no longer appear in user-facing method signatures.
v2.4.0 — watch mode
  • --watch — file polling loop, regenerates on change.
  • --interval=N — custom poll interval in ms.
  • Watcher class tracks files by filemtime.
v2.3.0 — :batch, :transaction, prepared statement cache
  • :batch — run same query N times in one transaction.
  • :transaction — orchestrate multiple methods in one transaction via @calls.
  • prepared_statement_cache: true — cache PDOStatement objects per method.
  • @dto — override DTO class name. @column — rename result column.
v2.2.0 — @embed, @nillable, @optional improvements
  • @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.
v2.1.0 — expression type inference
  • Numeric literals (0, 42, 3.14) resolve to int/float.
  • Arithmetic expressions resolve from left operand type.
  • IF() resolves from true branch; falls back to false branch when true is NULL.