PHPackages                             nandan108/attrecord - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Database &amp; ORM](/categories/database)
4. /
5. nandan108/attrecord

ActiveLibrary[Database &amp; ORM](/categories/database)

nandan108/attrecord
===================

Lightweight PHP 8.1+ attribute-driven active-record layer with dirty tracking, batch relation loading, deadlock-safe locking, and MySQL/PostgreSQL CREATE TABLE generation.

v0.1.0(today)00MITPHPPHP ^8.1CI failing

Since Jun 30Pushed todayCompare

[ Source](https://github.com/Nandan108/attrecord)[ Packagist](https://packagist.org/packages/nandan108/attrecord)[ Docs](https://github.com/Nandan108/attrecord)[ RSS](/packages/nandan108-attrecord/feed)WikiDiscussions main Synced today

READMEChangelog (1)Dependencies (4)Versions (2)Used By (0)

attrecord
=========

[](#attrecord)

[![CI](https://github.com/Nandan108/attrecord/actions/workflows/ci.yml/badge.svg)](https://github.com/Nandan108/attrecord/actions/workflows/ci.yml)[![Coverage](https://camo.githubusercontent.com/8f35fab6671c646ce3617e51d57161c4b5e2ee0c8d7244e210fe4b7e7a6b68b9/68747470733a2f2f636f6465636f762e696f2f67682f6e616e64616e3130382f6174747265636f72642f6272616e63682f6d61696e2f67726170682f62616467652e737667)](https://codecov.io/gh/nandan108/attrecord)[![Packagist Version](https://camo.githubusercontent.com/0f5e934e06177f80c76f72f7cda20a6fb7a0671315cc58ccc96c3c9f9ec4ef52/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f6e616e64616e3130382f6174747265636f7264)](https://packagist.org/packages/nandan108/attrecord)[![PHP Version](https://camo.githubusercontent.com/6e23b58af398f686098f98b41f055fb5b26a80d66cb2ecd72088ec93a45c3c5f/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f7068702d762f6e616e64616e3130382f6174747265636f7264)](https://packagist.org/packages/nandan108/attrecord)[![License](https://camo.githubusercontent.com/eac64fb4f45e148f642bd5e25d97eb3100ed2458251c8d5f493dc76c0b76603e/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f6c2f6e616e64616e3130382f6174747265636f7264)](LICENSE)

Lightweight PHP 8.1+ attribute-driven active-record layer.

- Declare schema with PHP attributes — no XML, no YAML, no separate migration files
- **Emit `CREATE TABLE` directly from the attributes** — single source of truth for column type, defaults, unique keys, indexes, and FK constraints; MySQL/MariaDB **and** PostgreSQL
- **Dialect-portable** — MySQL/MariaDB and PostgreSQL share one code path for CRUD, batch upserts, eager loading, DDL, and advisory locks; binary columns bind correctly on both (PG `bytea` included)
- **camelCase PHP / snake\_case SQL** via per-column `name:` override (no auto-conversion — [decision documented](docs/design-note-no-name-auto-conversion.md))
- Dirty-tracking — `save()` only writes changed columns
- Column casting — map columns to value objects / JSON / custom types via `#[Cast]` attributes ([docs](docs/column-casting.md))
- Bulk upsert via `RecordSet::saveAll()` with a single SQL statement
- Eager relation loading with no N+1 queries (`with()`)
- Domain invariants enforced at assignment and save time via a `validate()` hook
- Deadlock-safe locking helpers (`LockTier`, `LockSet`, `Transaction`) + advisory locks
- Unique-key aware upserts — single (`upsertByUniqueKey`) and bulk (`RecordSet::upsertAllByUniqueKey`), with an optional **auto-increment-burn-free** mode; plus `updateByUniqueKey`
- Constraint-only foreign keys via `#[ForeignKey]` — declare an FK whose target has no Record (or that you don't want to hydrate)
- Three included `DbSession` adapters: PDO, mysqli, and WordPress `wpdb`
- Psalm-clean at level 1

---

Installation
------------

[](#installation)

```
composer require nandan108/attrecord
```

Requires PHP 8.1+. No runtime dependencies.

---

Documentation
-------------

[](#documentation)

This README is the narrative guide. Deeper references live in [`docs/`](docs/):

- [llm-reference.md](docs/llm-reference.md) — exhaustive, AI-ingestion-oriented reference (every attribute, method signature, enum, dialect difference, and invariant in one place)
- [ddl-generation.md](docs/ddl-generation.md) — `CREATE TABLE` emission (MySQL + PostgreSQL)
- [column-casting.md](docs/column-casting.md) — the `#[Cast]` family and `JsonCastable`
- [where-clause.md](docs/where-clause.md) — the `WhereClause` builder grammar
- [polymorphic-relations.md](docs/polymorphic-relations.md) — morph relations
- [design-note-no-name-auto-conversion.md](docs/design-note-no-name-auto-conversion.md) — why no auto snake/camel conversion

---

Quick start
-----------

[](#quick-start)

### 1 — Define your records

[](#1--define-your-records)

```
use Nandan108\Attrecord\Record;
use Nandan108\Attrecord\Attribute\{Table, Column, Relation};
use Nandan108\Attrecord\Enum\{ColumnType, RelationType};

#[Table(name: 'orders')]
class Order extends Record
{
    #[Column(ColumnType::BigIntUnsigned, autoIncrement: true)]
    public ?int $id = null;

    #[Column(ColumnType::VarChar, length: 64)]
    public string $status = 'draft';

    #[Column(ColumnType::Decimal, precision: 10, scale: 2, nullable: true)]
    public ?float $total = null;

    #[Column(ColumnType::DateTime, nullable: true)]
    public ?\DateTimeImmutable $placed_at = null;

    /** @var RecordSet|null */
    #[Relation(RelationType::OneToMany, class: OrderLine::class, foreignKey: 'order_id')]
    public ?\Nandan108\Attrecord\RecordSet $lines = null;
}

#[Table(name: 'order_lines')]
class OrderLine extends Record
{
    #[Column(ColumnType::BigIntUnsigned, autoIncrement: true)]
    public ?int $id = null;

    #[Column(ColumnType::BigIntUnsigned)]
    public int $order_id = 0;

    #[Column(ColumnType::VarChar, length: 200)]
    public string $sku = '';

    #[Column(ColumnType::IntUnsigned)]
    public int $qty = 1;

    #[Relation(RelationType::ManyToOne, class: Order::class, foreignKey: 'order_id')]
    public ?Order $order = null;
}
```

### 2 — Bootstrap once

[](#2--bootstrap-once)

```
use Nandan108\Attrecord\Connection;
use Nandan108\Attrecord\Dialect\MysqlDialect;
use Nandan108\Attrecord\Session\PdoDbSession;

$pdo  = new PDO('mysql:host=127.0.0.1;dbname=shop;charset=utf8mb4', 'user', 'pass');
$conn = new Connection(new PdoDbSession($pdo), new MysqlDialect());

Record::setConnection($conn);
```

Per-class override (e.g. a multi-tenant setup):

```
Record::setConnection($tenantConn, forClass: Order::class);
```

Optional: prefix every `Record` subclass table name globally (useful for WordPress or multi-tenant single-DB setups). Call before any DB operation:

```
Record::setTablePrefix('wp_');   // Order → `wp_orders`, OrderLine → `wp_order_lines`
```

The prefix is prepended to whatever appears in `#[Table(name: …)]`. Changing it clears the schema cache so subsequent operations see the new prefix.

### 3 — Use

[](#3--use)

```
// INSERT — classic style
$order = new Order();
$order->status = 'pending';
$order->total  = 99.95;
$order->save();               // INSERT INTO `orders` …
echo $order->id;              // auto-assigned PK

// INSERT — fluent factory style
$order = Order::newWith(['status' => 'pending', 'total' => 99.95])->save();
echo $order->id;              // auto-assigned PK

// Bulk-assign on an existing instance
$order->set(['status' => 'confirmed', 'total' => 149.00])->save();

// set() calls validate() by default — pass false to defer validation
// (useful for test fixtures or staged construction across multiple set() calls).
// save() / saveAll() will still validate at the boundary.
$order->set(['status' => 'confirmed'], validate: false);

// save() always returns $this — check $_saved if you need to know whether a write occurred
$order->save();
$order->_saved;   // true  = INSERT or UPDATE was issued
                  // false = record was clean, nothing sent to DB
                  // null  = save() not yet called on this instance

// SELECT by PK
$order = Order::getOne(42);          // ?Order
$order = Order::getOneOrFail(42);    // Order  (throws RecordNotFoundException if missing)
$order = Order::getOneOrNew(42);     // Order (new, unsaved instance if missing)

// UPDATE — only dirty columns
$order->status = 'confirmed';
$order->save();   // UPDATE `orders` SET `status` = ? WHERE `id` = ?

// DELETE
$order->delete();

// Reload from DB (e.g. after an external update)
$order->reload();
```

---

Finders
-------

[](#finders)

```
// All records (no WHERE)
$all = Order::find();

// With WHERE clause — positional params
$pending = Order::find('`status` = ?', ['pending']);

// With WHERE clause — named params
$recent  = Order::find('`placed_at` > :since', ['since' => '2024-01-01']);

// ORDER BY / LIMIT
$top10 = Order::find('`total` > ?', [100], 'ORDER BY `total` DESC LIMIT 10');

// First match or null
$draft = Order::findOne('`status` = ?', ['draft']);

// findOne accepts ORDER BY and FOR UPDATE too
$latestPending = Order::findOne(
    '`status` = ?',
    ['pending'],
    orderByLimit: 'ORDER BY `placed_at` DESC',
    forUpdate:    true,    // inside transactional() only
);

// Count
$count = Order::countWhere('`status` = ?', ['pending']);

// Bulk update — column → value map; values are typed via the column's serializer
$updated = Order::updateWhere(
    ['status' => 'archived'],
    '`status` = ? AND `placed_at` < ?',
    ['draft', '2024-01-01'],
);

// Bulk delete
$deleted = Order::deleteWhere('`status` = ? AND `total` IS NULL', ['draft']);
```

### `RawSql` — raw SQL fragments with optional bound params

[](#rawsql--raw-sql-fragments-with-optional-bound-params)

`RawSql` wraps an untranslated SQL expression with optional `?`-placeholder params. The expression is embedded verbatim — **the caller is responsible for quoting identifiers and never embedding user input** — but values can still be bound safely through `?` placeholders.

```
use Nandan108\Attrecord\RawSql;

// Increment a counter — no params needed
Order::updateWhere(
    ['view_count' => new RawSql('`view_count` + 1')],
    '`id` = ?', [$id],
);

// Conditional bulk write
Order::updateWhere(
    ['priority' => new RawSql('CASE WHEN `total` > 500 THEN 1 ELSE 0 END')],
    '`status` = ?', ['pending'],
);

// Parameterised raw expression — RawSql params come BEFORE the WHERE params
Order::updateWhere(
    ['priority' => new RawSql('GREATEST(?, `priority`)', [5])],
    '`status` = ?', ['pending'],
);
```

The same `RawSql` can be reused as a WHERE condition via `WhereClause::whereRaw($raw)`, so a complex expression can be built once and applied in either position:

```
$jsonHas = new RawSql('JSON_CONTAINS(`tags`, ?)', ['"featured"']);

Order::find(WhereClause::whereRaw($jsonHas));
// ...
Order::updateWhere(
    ['featured_at' => new RawSql('NOW()')],
    WhereClause::whereRaw($jsonHas),
);
```

### Convenience finders

[](#convenience-finders)

Column names are automatically quoted by the class's configured dialect:

```
// Single-column equality
$pending = Order::where('status', 'pending');

// Comparison operator
$large = Order::where('total', 100, '>');

// NULL check  (null value → IS NULL / IS NOT NULL)
$unplaced = Order::where('placed_at', null);

// IN list
$active = Order::whereIn('status', ['pending', 'confirmed']);
```

### WhereClause builder

[](#whereclause-builder)

For programmatic conditions, compose a `WhereClause` and pass it to `find()`. Column names are stored unquoted and quoted for the target dialect at render time:

```
use Nandan108\Attrecord\WhereClause as WC;

$clause = WC::where('status', 'pending')
    ->andWhere(
        WC::where('total', 100, '>')
            ->orWhere(WC::where('flagged', true))
    );

$orders = Order::find($clause);
```

`Record::where()` / `whereIn()` handle quoting automatically. When building `WhereClause` directly, pass unquoted column names — quoting is applied by `find()`via the class's configured dialect.

See [docs/where-clause.md](docs/where-clause.md) for the full reference: `whereIn`, `whereInTuples`, `whereLike`, `whereBetween`, `whereNot`, `whereRaw`, variadic combinators, and the `render($dialect)` API.

---

Unique keys, indexes &amp; targeted upserts
-------------------------------------------

[](#unique-keys-indexes--targeted-upserts)

Declare non-PK unique keys with `#[UniqueKey('name')]` and non-unique secondary indexes with `#[Index('name')]`. Both attributes can be applied at either property or class level.

**Property level** (single-column keys, or composites with column ordering matching property declaration order):

```
use Nandan108\Attrecord\Attribute\{Column, UniqueKey, Index};

#[Table(name: 'inventory_items')]
class InventoryItem extends Record
{
    #[Column(ColumnType::BigIntUnsigned, autoIncrement: true)]
    public ?int $id = null;

    // Single-column unique key
    #[Column(ColumnType::VarChar, length: 64)]
    #[UniqueKey('sku')]
    public string $sku = '';

    // Compound unique key: (location_id, bin) — same name on both columns,
    // composite ordering follows property declaration order
    #[Column(ColumnType::BigIntUnsigned)]
    #[UniqueKey('loc_bin')]
    public int $location_id = 0;

    #[Column(ColumnType::VarChar, length: 32)]
    #[UniqueKey('loc_bin')]
    public string $bin = '';

    // Single-column secondary index
    #[Column(ColumnType::IntUnsigned)]
    #[Index('idx_qty')]
    public int $qty = 0;
}
```

**Class level** (composite keys with explicit column ordering, independent of property declaration order):

```
#[Table(name: 'inventory_items')]
#[UniqueKey('uk_loc_bin',   columns: ['location_id', 'bin'])]
#[Index    ('idx_loc_qty',  columns: ['location_id', 'qty'])]
class InventoryItem extends Record { /* ... */ }
```

Class-level form **requires** `columns: [...]`; property-level form **forbids** it. A given key/index name must be declared via one form only.

### `upsertByUniqueKey($conflictKey, $updateColumns)`

[](#upsertbyuniquekeyconflictkey-updatecolumns)

INSERT this record; on conflict on the named unique key, UPDATE only the listed columns. Dialect-aware (uses `ON DUPLICATE KEY UPDATE` on MySQL/MariaDB, `ON CONFLICT … DO UPDATE` on PostgreSQL).

```
$item = new InventoryItem();
$item->sku = 'WIDGET-1';
$item->location_id = 1;
$item->bin = 'A-01';
$item->qty = 10;

// Insert if new; on SKU conflict, only overwrite qty
$item->upsertByUniqueKey('sku', updateColumns: ['qty']);
```

#### Burn-free mode — `preserveAutoIncrement: true`

[](#burn-free-mode--preserveautoincrement-true)

`INSERT … ON DUPLICATE KEY UPDATE` allocates **and discards** an auto-increment value on every conflicting write (MySQL/MariaDB with `innodb_autoinc_lock_mode = 1`), so an idempotent re-write of an existing row silently inflates the counter — a problem for small id domains re-registered on every request (registries, config rows).

Pass `preserveAutoIncrement: true` to get a SELECT-then-`UPDATE`/`INSERT` instead: the row is looked up by the conflict key and updated in place when it exists (no allocation), and inserted only when genuinely new. The cost is a second statement and a small non-atomic window — fine for low-concurrency registry/config writes; prefer the atomic default when burn is a non-issue.

```
// Re-registering the same SKU never advances the auto-increment counter
$item->upsertByUniqueKey('sku', updateColumns: ['qty'], preserveAutoIncrement: true);
```

A conflict key that includes a **generated column** (e.g. a `STORED``IFNULL(scope_id, 0)`) works too — set the property to the value the DB will compute so the lookup matches; the column is still skipped in the INSERT (the DB recomputes it).

### `updateByUniqueKey($fields = [])`

[](#updatebyuniquekeyfields--)

Direct UPDATE without loading the row first. The WHERE clause is built automatically from the PK if non-null, else from the first declared `#[UniqueKey]` whose columns are all non-null. With an empty `$fields`, all non-null non-PK non-autoIncrement columns are written; pass an explicit list when you need to write nulls or restrict the SET clause.

```
$item = new InventoryItem();
$item->sku = 'WIDGET-1';   // matches the 'sku' unique key
$item->qty = 25;

// UPDATE inventory_items SET qty = 25 WHERE sku = 'WIDGET-1'
$affected = $item->updateByUniqueKey();

// Restrict / allow nulls explicitly
$item->updateByUniqueKey(fields: ['qty', 'notes']);
```

Returns the affected row count (0 if no match, 1 on success). Throws `AttrecordException` when no viable WHERE clause can be built.

---

Schema generation — `CREATE TABLE` from your attributes
-------------------------------------------------------

[](#schema-generation--create-table-from-your-attributes)

Emit a fresh-install `CREATE TABLE` statement directly from the compiled `TableSchema`. The same attribute metadata that drives CRUD also drives DDL — no parallel hand-maintained DDL string.

```
use Nandan108\Attrecord\Dialect\MysqlDialect;
use Nandan108\Attrecord\Schema\TableSchema;

$sql = (new MysqlDialect())->buildCreateTable(
    TableSchema::fromClass(Order::class),
);
// CREATE TABLE `orders` (
//   `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
//   `customer_id` BIGINT UNSIGNED NOT NULL,
//   `status` VARCHAR(20) NOT NULL DEFAULT 'pending',
//   `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
//   `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
//                ON UPDATE CURRENT_TIMESTAMP,
//   PRIMARY KEY (`id`),
//   UNIQUE KEY `uk_external` (`external_ref`),
//   KEY `idx_status_date` (`status`, `created_at`),
//   CONSTRAINT `fk_orders_customer_id` FOREIGN KEY (`customer_id`)
//     REFERENCES `customers` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT
// ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```

Both dialects implement it. `PgsqlDialect::buildCreateTable()` emits the PostgreSQL equivalent from the same attributes — `BIGSERIAL` for an auto-increment PK, no `UNSIGNED`, `BYTEA` for binary, `NUMERIC` for decimal, `BOOLEAN`, `JSONB`, an `Enum` column as `TEXT`plus a `CHECK (... IN (...))` constraint, and FK constraints. Because PostgreSQL cannot declare secondary indexes or comments inline, those are emitted as trailing `CREATE INDEX`and `COMMENT ON` statements in the same (semicolon-separated) batch — safe to run in one `PDO::exec()`. Engine/charset/collation table options and a column `ON UPDATE` clause are MySQL-isms with no PostgreSQL column-clause equivalent and are not emitted; a `VIRTUAL`generated column and the `Set` type are rejected with a `SchemaException`.

```
use Nandan108\Attrecord\Dialect\PgsqlDialect;

$sql = (new PgsqlDialect())->buildCreateTable(TableSchema::fromClass(Order::class));
// CREATE TABLE "orders" (
//   "id" BIGSERIAL,
//   "customer_id" BIGINT NOT NULL,
//   "status" VARCHAR(20) NOT NULL DEFAULT 'pending',
//   ...
//   PRIMARY KEY ("id"),
//   CONSTRAINT "uk_external" UNIQUE ("external_ref"),
//   CONSTRAINT "fk_orders_customer_id" FOREIGN KEY ("customer_id")
//     REFERENCES "customers" ("id") ON DELETE CASCADE ON UPDATE RESTRICT
// );
// CREATE INDEX "idx_status_date" ON "orders" ("status", "created_at")
```

### Attribute fields used in DDL emission

[](#attribute-fields-used-in-ddl-emission)

`#[Column]` additions beyond type/length/nullable:

```
#[Column(
    type:        ColumnType::DateTime,
    default:     null,                    // literal default (int|float|string|bool|null)
    defaultExpr: 'CURRENT_TIMESTAMP',     // raw SQL default expression (mutually exclusive with default)
    onUpdate:    'CURRENT_TIMESTAMP',     // raw SQL ON UPDATE clause
    comment:     'When the order was placed',
    enumValues:  null,                    // list — required for ColumnType::Enum and Set
)]
```

`#[Table]` carries only cross-dialect fields (`name`, `primaryKey`, `comment`). MySQL-specific options live on a separate `#[MysqlTableOptions]` class-level attribute that other dialects ignore. Every field is nullable so you override only what you care about; `MysqlDialect` supplies sensible defaults (`InnoDB` / `utf8mb4` / `utf8mb4_unicode_ci`) for fields left null and for Records that omit `#[MysqlTableOptions]` entirely.

```
use Nandan108\Attrecord\Attribute\{Table, MysqlTableOptions};

#[Table(name: 'orders', primaryKey: 'id', comment: 'Customer orders')]
#[MysqlTableOptions(engine: 'Memory')]   // override engine only; charset/collation stay default
final class Order extends Record { /* ... */ }
```

A future `#[PgsqlTableOptions(...)]` will carry Postgres-specific options (tablespace, UNLOGGED, etc.) following the same pattern.

`#[Relation]` FK-constraint controls:

```
#[Relation(
    type:       RelationType::ManyToOne,
    class:      Customer::class,
    foreignKey: 'customer_id',
    onDelete:   ForeignKeyAction::Cascade,    // default: Restrict
    onUpdate:   ForeignKeyAction::Restrict,   // default: Restrict
    emitFk:     true,                          // opt-out per-relation
)]
```

FK constraints are emitted only for owning-side relations (`ManyToOne`, `OneToOne`). Polymorphic and inverse-side relations carry no local FK column and are always skipped.

#### Constraint-only foreign keys — `#[ForeignKey]`

[](#constraint-only-foreign-keys--foreignkey)

`#[Relation]` emits an FK *and* gives you object hydration. When you want the FK constraint **only** — or the target has no Record at all — use the class-level, repeatable `#[ForeignKey]` attribute. The local column is a plain `#[Column]` on this Record; the attribute names the target, which may be either a **Record class-string**(table + PK derived from it, rename-safe) or a **table name** string (for a hand-written or externally owned table attrecord doesn't model):

```
use Nandan108\Attrecord\Attribute\{Table, Column, ForeignKey};
use Nandan108\Attrecord\Enum\ForeignKeyAction;

#[Table(name: 'inventory_ledger')]
#[ForeignKey(column: 'subject_id', references: Subject::class)]                       // → `subjects`(`id`), derived
#[ForeignKey(column: 'from_slot_id', references: 'slotspace', referencesColumn: 'id', // → raw table, no Record
    onDelete: ForeignKeyAction::SetNull)]
final class InventoryLedger extends Record
{
    #[Column(ColumnType::BigIntUnsigned)]
    public int $subject_id = 0;

    #[Column(ColumnType::BigIntUnsigned, nullable: true)]
    public ?int $from_slot_id = null;
    // ...
}
```

Parameters: `column` (local FK column), `references` (target Record class **or** table name), `referencesColumn` (target column, default `id`), `onDelete` / `onUpdate`(default `Restrict`). The active table prefix is applied to a literal table name; the target is resolved lazily at DDL-build time. A `references` value that is a class but **not** a `Record` subclass throws.

Schema-build time validation surfaces mistakes early: `VarChar`/`Char`/`Decimal`/ `Enum`/`Set` required arguments, mutually exclusive `default` / `defaultExpr`, class- vs property-level key form conflicts, FK column references.

### Generated columns

[](#generated-columns)

A column whose value is computed by the database (`GENERATED ALWAYS AS (...)`) is declared by adding `generatedAs:` to the `#[Column]` attribute. The PHP property becomes effectively read-only: attrecord excludes generated columns from every INSERT and UPDATE it emits — assigning a value in PHP simply has no effect on the row.

```
use Nandan108\Attrecord\Attribute\Column;
use Nandan108\Attrecord\Enum\ColumnType;
use Nandan108\Attrecord\Enum\GeneratedColumnMode;

#[Column(
    type:           ColumnType::IntUnsigned,
    generatedAs:    'IFNULL(scope_actor_id, 0)',
    generatedMode:  GeneratedColumnMode::Stored,   // or Virtual; defaults to Stored
)]
public int $scope_actor_key = 0;
```

Emitted DDL (the `scope_actor_key` column participates in compound keys, indexes, and FK targets just like a regular column):

```
`scope_actor_key` INT UNSIGNED GENERATED ALWAYS AS (IFNULL(scope_actor_id, 0)) STORED,
```

`STORED` columns are materialized on disk (indexable without restriction); `VIRTUAL` columns are recomputed on each read (no storage cost, indexable in MySQL 8+ with caveats).

Schema-build validation enforces the mutual exclusions MySQL/MariaDB also enforce at DDL time:

- `default` / `defaultExpr` not allowed on a generated column
- `onUpdate` not allowed
- `autoIncrement` not allowed
- `generatedAs` must be a non-empty SQL expression
- `generatedMode` without `generatedAs` is rejected

`NULL` / `NOT NULL` is intentionally not emitted on generated columns — MySQL accepts it but MariaDB rejects it, and the generated expression already determines nullability. Use this for portable schemas across both engines.

### Out of scope

[](#out-of-scope)

`ALTER TABLE` generation, schema diffing, and migration tracking are **deliberately out of scope** of attrecord itself. They belong in a separate package built on top of `TableSchema`.

→ See [docs/ddl-generation.md](docs/ddl-generation.md) for the full reference (type rendering table, column-line format, validation rules, testing strategy).

### `updateByWhere($where, $params = [], $fields = [])`

[](#updatebywherewhere-params---fields--)

Bulk UPDATE driven by instance properties — same SET-clause semantics as `updateByUniqueKey()` but with a caller-supplied WHERE clause. Useful when you want type-safe value assignment via record properties but the WHERE is not derivable from a PK or declared unique key.

```
$proto = new InventoryItem();
$proto->qty = 0;

// Zero out qty for every item at a given location
$proto->updateByWhere('`location_id` = ?', [$locationId]);
```

---

Dirty tracking
--------------

[](#dirty-tracking)

```
$order = Order::hydrateFromArray(['id' => 1, 'status' => 'draft', 'total' => null, 'placed_at' => null]);

$order->isDirty();              // false — just loaded
$order->status = 'confirmed';
$order->isDirty();              // true
$order->isDirty('status');      // true
$order->isDirty('total');       // false

$order->dirtyFields();
// ['status' => ['draft', 'confirmed']]  (snapshot → current)
```

---

Column casting
--------------

[](#column-casting)

By default each column maps to a native PHP type from its `ColumnType`. **Casting** lets a property hold a richer value — a value object, a typed array, a decoded JSON payload — serialized transparently on write and reconstructed on read. It's opt-in: native types keep their built-in mapping, and a cast only kicks in where you ask for one (or where the native default would be wrong, e.g. an `array`-typed `Json` column).

```
// array ⇄ JSON — auto-attached on a Json column typed as array
#[Column(ColumnType::Json, nullable: true)]
public ?array $meta = null;

// value object ⇄ JSON — auto-attached when the type implements JsonCastable
#[Column(ColumnType::Json, nullable: true)]
public ?Money $price = null;

// explicit, parameterized caster
#[Column(ColumnType::Json, nullable: true)]
#[JsonCaster(excludeNullFields: ['note'])]
public ?array $audit = null;

// reshape a native type — e.g. store a timestamp as a unix int
#[Column(ColumnType::BigIntUnsigned, nullable: true)]
#[EpochCaster]
public ?\DateTimeImmutable $logged_at = null;
```

A caster *is* its attribute: `JsonCaster` / `DateTimeCaster` / `EpochCaster` ship built-in, and custom casters extend the `Cast` base (which implements the two-method `ColumnCaster` contract). Casting integrates with dirty tracking — including mutable value objects — and with bulk `saveAll()`, and has no effect on generated DDL.

→ See [docs/column-casting.md](docs/column-casting.md) for the full reference: the `ColumnCaster` contract, `JsonCastable` value objects, discriminated payloads, auto-attach rules, and limitations.

---

Validation
----------

[](#validation)

Subclasses override `validate()` to enforce domain invariants — field-level rules (positive ids, non-empty required strings) and cross-field constraints (mutually exclusive flags, dates ordered, etc.). The base implementation is a no-op, so records without invariants need not override.

```
use Nandan108\Attrecord\Exception\RecordValidationException;

class Order extends Record
{
    // ... columns ...

    public function validate(): void
    {
        if ($this->total !== null && $this->total < 0) {
            throw new RecordValidationException(
                'Order total cannot be negative.',
                context: ['total' => $this->total],
            );
        }
        if ($this->status === 'shipped' && $this->placed_at === null) {
            throw new RecordValidationException('Shipped order must have a placed_at.');
        }
    }
}
```

`validate()` runs automatically at three points:

- At the end of `set()` when `$validate` is true (the default) — catches invalid state at the point of mass assignment.
- Inside `save()` just after `beforeSave()` — guarantees no invalid row reaches the DB, even if a caller bypassed `set()` and assigned properties directly.
- Inside `RecordSet::saveAll()` in the same loop as `beforeSave()`.

Throw `RecordValidationException` (or a subclass) with a human-readable message and optional `context` array. The context is stored on the exception for the caller's error handling.

---

RecordSet
---------

[](#recordset)

`find()` returns a `RecordSet` — a typed, iterable collection.

```
$orders = Order::find('`status` = ?', ['pending']);

count($orders);             // Countable
foreach ($orders as $o) {} // Iterator

$orders->first();           // ?Order
$orders->last();            // ?Order

// Extract one field, keyed by the PK
$idToTotal = $orders->pluck('total');           // [pk => total]

// Extract multiple fields, keyed by the PK
$details = $orders->pluck(['status', 'total']); // [pk => ['status' => …, 'total' => …]]

// Group + extract — extra args are the grouping key(s); leaves are field values
$byStatusTotals = $orders->pluck('total', 'status');
// ['pending' => [10.0, 25.5, …], 'confirmed' => [99.95, …]]

$byStatusDetails = $orders->pluck(['id', 'total'], 'status');
// ['pending' => [['id' => 1, 'total' => 10.0], …], …]

// Index by a unique column
$byId = $orders->recordsByKey('id');  // array

// Group by a single column
$byStatus = $orders->recordsGroupedByKey('status');  // array

// Nested group by multiple columns — leaves are RecordSets, not plain arrays
$byStatusByYear = $orders->recordsGroupedByKeys('status', 'year');
// ['pending' => [2024 => RecordSet, 2025 => RecordSet], …]

// Convert to raw arrays (column → scalar) — useful for serialisation
$rows = $orders->toArraySet();   // list
```

### Bulk operations

[](#bulk-operations)

```
// Stamp a shared field on every record before saving (e.g. updated_at, actor_id)
$set = new RecordSet([$line1, $line2, $line3]);
$set->bulkSet(['updated_by' => $userId]);

// Batch upsert — deadlock-safe 3-step strategy for all dirty records
$result = $set->saveAll();   // ?SaveResult — null when nothing to save

$result->inserted;      // rows newly written
$result->updated;       // rows overwritten
$result->total();       // inserted + updated
$result->insertedIds;   // list — PKs of newly inserted auto-increment records

// Force-save (skip dirty filter) — useful in tests and for re-asserting state
$set->saveAll(force: true);

// Bulk delete
$deleted = $set->deleteAll();  // DELETE FROM … WHERE id IN (…)
```

**Notes on `saveAll()`:**

- Clean records are skipped automatically (pass `force: true` to override).
- `beforeSave()` and `validate()` run on every dirty record before any SQL is issued.
- `insertedIds` is populated for new (no-PK) records: via `RETURNING` on PostgreSQL, or via `lastInsertId()` + sequential range on MySQL/MariaDB. On MySQL/MariaDB clustered setups with non-sequential auto-increment, use individual `Record::save()` calls instead.
- For tables with natural (non-auto-increment) PKs, `saveAll()` performs a true upsert (PKs are already set on the PHP objects).

`Record::save()` accepts the same `$force` flag — `$order->save(force: true)` writes every column regardless of dirty state.

### `upsertAllByUniqueKey($conflictKey)` — bulk burn-free upsert

[](#upsertallbyuniquekeyconflictkey--bulk-burn-free-upsert)

The loop-free, auto-increment-burn-free counterpart of an `INSERT … ON DUPLICATE KEY UPDATE` batch — the `RecordSet` analogue of `Record::upsertByUniqueKey(..., preserveAutoIncrement: true)`.

```
// $rows are PK-less Records whose conflict-key column(s) are set
$result = (new RecordSet($rows))->upsertAllByUniqueKey('uniq_owner_code');
```

One `SELECT … WHERE (conflict cols) IN (…)` resolves the PKs of rows that already exist and assigns them onto the matching records; `saveAll()` then routes those through its keyed upsert (PK supplied → no allocation) while genuinely-new records take its plain bulk `INSERT` (one id each, none wasted). Returns the same `?SaveResult` as `saveAll()`(`null` for an empty set). Records that already carry a PK are left untouched. Same non-atomic caveat as the single-record burn-free path. Throws `AttrecordException` if `$conflictKey` isn't a declared `#[UniqueKey]`.

---

Eager relation loading
----------------------

[](#eager-relation-loading)

Avoids N+1 with a single extra query per relation level:

```
// One extra query per level
$orders = Order::find('`status` = ?', ['pending'])
    ->with('lines');          // SELECT … WHERE order_id IN (…)

foreach ($orders as $order) {
    foreach ($order->lines as $line) {
        echo $line->sku;
    }
}

// Dot-notation chains
$orders->with('lines.product');  // loads lines, then products for those lines
```

---

Polymorphic relations
---------------------

[](#polymorphic-relations)

Polymorphic relations let one table reference rows from multiple other tables through a type-discriminator column and a shared FK column.

```
tags
  id            bigint PK
  tagable_type  varchar   ← discriminator: 'order' | 'product' | ...
  tagable_id    bigint    ← FK to the matching table's PK
  name          varchar

```

### Declaring the schema

[](#declaring-the-schema)

```
// Parent side — Order has many Tags
#[Table(name: 'orders')]
class Order extends Record
{
    // …

    /** @var RecordSet|null */
    #[Relation(RelationType::MorphMany, class: Tag::class,
        morphType: 'tagable_type', morphKey: 'tagable_id',
        morphValue: 'order')]
    public ?RecordSet $tags = null;

    // MorphOne: same as MorphMany but returns a single record or null
    #[Relation(RelationType::MorphOne, class: Tag::class,
        morphType: 'tagable_type', morphKey: 'tagable_id',
        morphValue: 'order')]
    public ?Tag $primaryTag = null;
}

// Child side — Tag belongs to a polymorphic parent
#[Table(name: 'tags')]
class Tag extends Record
{
    #[Column(ColumnType::VarChar, length: 50)]
    public string $tagable_type = '';

    #[Column(ColumnType::BigIntUnsigned)]
    public int $tagable_id = 0;

    #[Relation(RelationType::MorphTo,
        morphType: 'tagable_type',
        morphKey: 'tagable_id',
        morphMap: ['order' => Order::class, 'product' => Product::class])]
    public Order|Product|null $tagable = null;
}
```

`morphValue` can be a string or an integer — use integers when the discriminator column is an FK into a type-lookup table (see [docs/polymorphic-relations.md](docs/polymorphic-relations.md)).

### Eager loading

[](#eager-loading)

```
// Load orders with all their tags — one extra query
$orders = Order::find('`status` = ?', ['pending'])->with('tags');

foreach ($orders as $order) {
    foreach ($order->tags as $tag) {
        echo $tag->name;
    }
}

// Load tags with their polymorphic parent — one query per distinct type present
$tags = Tag::find()->with('tagable');

foreach ($tags as $tag) {
    // $tag->tagable is an Order or Product depending on tagable_type
}

// Chains work too: orders → tags → tagable (round-trip)
$orders->with('tags.tagable');
```

`with('tagable')` issues one `IN(…)` query per distinct type value present in the result set — not one query per row.

Tags whose `tagable_type` has no entry in `morphMap` are silently skipped (property stays `null`). This makes schema evolution safe: new type values added to the DB before the PHP code is updated will not cause errors.

→ See [docs/polymorphic-relations.md](docs/polymorphic-relations.md) for schema design advice, trade-offs, and integer discriminator patterns.

---

Transactions
------------

[](#transactions)

```
Order::transactional(function (Transaction $tx): void {
    $order = Order::getOne(42, forUpdate: true, tx: $tx);
    $order->status = 'shipped';
    $order->save();

    $line = new OrderLine();
    $line->order_id = $order->id;
    $line->sku = 'WIDGET-1';
    $line->save();
});
// Automatically committed; rolled back on exception
```

Nested `transactional()` calls are safe — only the outermost call issues `BEGIN` / `COMMIT` / `ROLLBACK`.

---

Deadlock-safe locking
---------------------

[](#deadlock-safe-locking)

Declare a lock tier on each entity to enforce a consistent lock order across all code paths:

```
use Nandan108\Attrecord\Attribute\LockTier;

#[Table(name: 'orders')]
#[LockTier(1)]
class Order extends Record { ... }

#[Table(name: 'order_lines')]
#[LockTier(2)]
class OrderLine extends Record { ... }
```

Inside a transaction, `getOne(..., forUpdate: true, tx: $tx)` registers the lock. Attempting to acquire a lower-tier lock after a higher-tier one throws `LockTierConflictException`, preventing deadlocks at the application level.

```
Order::transactional(function (Transaction $tx): void {
    $order = Order::getOne(1, forUpdate: true, tx: $tx);      // tier 1 ✓
    $line  = OrderLine::getOne(5, forUpdate: true, tx: $tx);  // tier 2 ✓
    // OrderLine::getOne after Order::getOne is safe (2 > 1)

    // Reversed order would throw LockTierConflictException
});
```

### `LockSet::acquire()` — multi-class lock acquisition

[](#locksetacquire--multi-class-lock-acquisition)

For compound operations that lock rows across several entity classes at once, use `LockSet::acquire()`. It sorts the targets by their declared `#[LockTier]` (lowest first), then issues `SELECT … FOR UPDATE` with `ORDER BY pk ASC` within each table. This eliminates the class of deadlock caused by inconsistent acquisition order across concurrent transactions.

```
use Nandan108\Attrecord\LockSet;

PurchaseOrder::transactional(function (Transaction $tx) use ($poId, $lineIds, $slotId): void {
    $session = PurchaseOrder::connection()->session;

    $locks = LockSet::acquire($session, [
        PurchaseOrder::class     => [$poId],
        PurchaseOrderLine::class => $lineIds,
        InventorySlot::class     => [$slotId],
    ], $tx);

    // $locks[PurchaseOrder::class]     is RecordSet
    // $locks[PurchaseOrderLine::class] is RecordSet
    foreach ($locks[PurchaseOrderLine::class] as $line) {
        // … process under lock
    }
});
```

Throws `MissingLockTierException` if any target class lacks `#[LockTier]`, and `LockTierConflictException` if two classes share the same tier in the same set.

### Advisory locks

[](#advisory-locks)

`DbSession::withAdvisoryLock()` provides named application-level mutexes — backed by `GET_LOCK` / `RELEASE_LOCK` on MySQL/MariaDB, and by `pg_advisory_lock` (keyed on a crc32 hash of the lock name) on a PostgreSQL PDO connection, where the wait timeout is emulated by polling `pg_try_advisory_lock`. Advisory locks are connection-scoped and do not interact with row or table locks — safe to nest inside a transaction.

```
$conn = Record::connection();

$conn->session->withAdvisoryLock(
    lockName:       'invflux.reconcile.shipment-42',
    timeoutSeconds: 5,          // 0 = fail immediately, -1 = wait indefinitely
    callback:       function () {
        // ... serialise this critical section across all PHP workers
    },
);
```

---

DB session adapters
-------------------

[](#db-session-adapters)

All three implement `DbSession` and can be swapped without changing application code.

### PDO (recommended for new projects)

[](#pdo-recommended-for-new-projects)

```
use Nandan108\Attrecord\Session\PdoDbSession;

$pdo  = new PDO('mysql:host=127.0.0.1;dbname=shop', 'user', 'pass');
$conn = new Connection(new PdoDbSession($pdo), new MysqlDialect());
```

### mysqli

[](#mysqli)

```
use Nandan108\Attrecord\Session\MysqliDbSession;

$mysqli = new mysqli('127.0.0.1', 'user', 'pass', 'shop');
$conn   = new Connection(new MysqliDbSession($mysqli), new MysqlDialect());
```

### WordPress wpdb

[](#wordpress-wpdb)

```
use Nandan108\Attrecord\Session\WpDbSession;

global $wpdb;
$conn = new Connection(new WpDbSession($wpdb), new MysqlDialect());
Record::setConnection($conn);
```

`WpDbSession` converts attrecord's `?` placeholders to `%s` for `wpdb::prepare()` and escapes existing `%` in LIKE clauses automatically.

---

Unit testing with CapturingDbSession
------------------------------------

[](#unit-testing-with-capturingdbsession)

`src/Test/CapturingDbSession.php` records SQL without touching a database:

```
use Nandan108\Attrecord\Test\CapturingDbSession;
use Nandan108\Attrecord\Connection;
use Nandan108\Attrecord\Dialect\MysqlDialect;

$session = new CapturingDbSession();
Record::setConnection(new Connection($session, new MysqlDialect()));

$order = new Order();
$order->status = 'pending';
$order->save();

assertStringContainsString('INSERT INTO `orders`', $session->lastSql());
assertSame(['pending'], $session->lastParams());

// Control the returned PK
$session->setNextInsertId(100);
$order2 = new Order();
$order2->status = 'draft';
$order2->save();
assertSame(100, $order2->id);

// Full call log
$session->allCalls();  // list
$session->reset();     // clear log
```

---

Property name vs column name
----------------------------

[](#property-name-vs-column-name)

PHP convention is `camelCase`; SQL convention is `snake_case`. Each `#[Column]`property may declare an explicit column name; when omitted, the column name equals the PHP property name:

```
#[Table(name: 'orders', primaryKey: 'order_id')]
final class Order extends Record
{
    #[Column(ColumnType::BigIntUnsigned, name: 'order_id', autoIncrement: true)]
    public ?int $orderId = null;

    #[Column(ColumnType::BigIntUnsigned, name: 'customer_id')]
    public int $customerId = 0;

    // No `name:` override — column name equals property name
    #[Column(ColumnType::VarChar, length: 20)]
    public string $status = 'pending';
}
```

`#[Table(primaryKey: …)]` references the **column** name (not the property name).

Internally, `TableSchema` exposes both sides:

- `$schema->pk` — primary-key column name (used in SQL).
- `$schema->pkProp` — primary-key property name (used for PHP property access).
- `$schema->columns[$colName]->name` / `->propertyName` — same pairing per column.
- `$schema->propFor(string $colName): string` — helper that resolves column → property.

**No auto-conversion** is provided (not by default, not as opt-in). The rationale is documented in [docs/design-note-no-name-auto-conversion.md](docs/design-note-no-name-auto-conversion.md) — short version: it would turn IDE Rename Symbol into a silent schema migration, hide column names from `grep`, and introduce an algorithmic derivation rule that has to be remembered everywhere.

---

Column types
------------

[](#column-types)

PHP type`ColumnType` cases`int``TinyInt`, `SmallInt`, `MediumInt`, `Int`, `BigInt`, `*Unsigned`, `Year`, `Bit``bool``Bool``float``Float`, `Double`, `Decimal``string``Char`, `VarChar`, `TinyText`, `Text`, `MediumText`, `LongText`, `Json`, `Enum`, `Set`, `Binary`, `VarBinary``\DateTimeImmutable``Date`, `DateTime`, `Timestamp`**Column options:**

```
#[Column(
    type:          ColumnType::VarChar,
    name:          'col_name',  // SQL column name override (defaults to PHP property name)
    nullable:      true,        // allows NULL; PHP property becomes ?string
    autoIncrement: true,        // skipped in INSERT/UPDATE; PK assigned after INSERT
    trimOnSave:    true,        // trim whitespace on save; also suppresses dirty-detection for whitespace-only changes
    length:        255,         // for VarChar/Char/Binary/VarBinary; also enforced at DDL generation time
    precision:     10,          // Decimal: total digits (required, paired with scale); DateTime/Timestamp: fractional-seconds 0-6 (optional)
    scale:         2,           // Decimal scale (required); forbidden on other types
    default:       null,        // literal DEFAULT value (int|float|string|bool|null); see DDL section
    defaultExpr:   null,        // raw SQL DEFAULT expression, e.g. 'CURRENT_TIMESTAMP'
    onUpdate:      null,        // raw SQL ON UPDATE expression, e.g. 'CURRENT_TIMESTAMP'
    comment:       null,        // column comment (DDL-only)
    enumValues:    null,        // list — required for ColumnType::Enum and Set
)]
```

### Binary columns

[](#binary-columns)

`Binary` / `VarBinary` columns hold raw bytes (e.g. an application-minted `BINARY(16)` / `BYTEA` UUID primary key). Reads and writes through the normal `save()` / `getOne()` / `find()` / `saveAll()` paths handle binary transparently on both MySQL and PostgreSQL.

The handling is **dialect-gated**, so it's invisible to MySQL consumers: on MySQL/MariaDB, binary values bind as ordinary byte strings exactly as any other string (so a custom `DbSession` that only accepts scalars keeps working). Only when the active dialect reports `bindsBinaryAsLob() === true` (PostgreSQL) does `toParam()` wrap binary values in a `BinaryParam` so the session can bind them as a `bytea` LOB; reads decode the `bytea` wire stream back to raw bytes. Net effect: a non-UTF-8 byte string round-trips correctly on both engines, and nothing changes for a MySQL-only deployment.

The one case that needs help is an **ad-hoc `WhereClause` predicate on a binary column**, where attrecord has no column metadata to drive the binding. On PostgreSQL, wrap the value in `Nandan108\Attrecord\BinaryParam` so the session binds it as binary rather than text (on MySQL a plain byte string works, but wrapping is harmless):

```
use Nandan108\Attrecord\BinaryParam;
use Nandan108\Attrecord\WhereClause;

Subject::find(WhereClause::where('uuid', new BinaryParam($rawBytes)));
```

Binary lookups by **primary key** (`getOne($rawBytes)`, `delete()`) need no wrapping — the PK column type is known and the wrapping is applied for you.

---

Relation types
--------------

[](#relation-types)

`RelationType`FK locationPHP property typeRequired parameters`OneToMany`Related table has FK pointing here`?RecordSet``class`, `foreignKey``ManyToOne`This table has FK pointing to related PK`?T``class`, `foreignKey``OneToOne`This table has FK`?T``class`, `foreignKey``OneToOneReversed`Related table has FK`?T``class`, `foreignKey``MorphMany`Related table has type+FK pointing here`?RecordSet``class`, `morphType`, `morphKey`, `morphValue``MorphOne`Related table has type+FK pointing here`?T``class`, `morphType`, `morphKey`, `morphValue``MorphTo`This table has type+FK columns`?T` (union)`morphType`, `morphKey`, `morphMap````
// Standard relation
#[Relation(
    type:       RelationType::OneToMany,
    class:      OrderLine::class,   // target Record subclass
    foreignKey: 'order_id',         // FK column name
    localKey:   'id',               // optional; defaults to this table's PK
)]

// Polymorphic parent
#[Relation(
    type:       RelationType::MorphMany,
    class:      Tag::class,
    morphType:  'tagable_type',     // type-discriminator column on the related table
    morphKey:   'tagable_id',       // FK column on the related table
    morphValue: 'order',            // value stored in morphType for this class (string or int)
)]

// Polymorphic child
#[Relation(
    type:      RelationType::MorphTo,
    morphType: 'tagable_type',      // local type-discriminator column
    morphKey:  'tagable_id',        // local FK column
    morphMap:  ['order' => Order::class, 'product' => Product::class],
)]
```

---

Running tests
-------------

[](#running-tests)

```
# Unit tests (no DB needed)
composer test -- --testsuite unit

# Integration tests (requires MariaDB + PostgreSQL)
docker compose up -d
composer test -- --testsuite integration

# All tests
composer test

# One backend only (the integration suites are tagged by @group)
composer test -- --testsuite integration --group mysql
composer test -- --testsuite integration --group pgsql
```

Each integration suite is a shared body of test cases (a `…Cases` trait under `tests/Integration/Cases/`) bound to two thin concrete classes — one per backend — so the **same assertions run against MySQL and PostgreSQL**. Each suite's schema is generated from its fixtures' attributes via `buildCreateTable()`, so the DDL producer is exercised on both engines on every run. PostgreSQL tests skip (rather than fail) when the container is absent.

Environment variables for integration tests (defaults shown):

```
# MySQL / MariaDB
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=attrecord_test
DB_USER=root
DB_PASS=root

# PostgreSQL (tests skipped if unavailable)
PGSQL_HOST=127.0.0.1
PGSQL_PORT=5432
PGSQL_DB=attrecord_test
PGSQL_USER=postgres
PGSQL_PASS=postgres

```

### Code style &amp; static analysis

[](#code-style--static-analysis)

Code style is enforced with [PHP CS Fixer](https://cs.fixer.dev/) (the `@Symfony` ruleset plus project overrides in `.php-cs-fixer.php`), and types with [Psalm](https://psalm.dev/) at level 1:

```
composer cs-fix     # apply PHP CS Fixer
composer cs-check   # report style violations without changing files (used in CI)
composer psalm      # static analysis — must be zero errors
```

All three (tests, Psalm, PHP CS Fixer) run in CI against PHP 8.1–8.4 with MySQL and PostgreSQL.

---

Contributing
------------

[](#contributing)

Issues and pull requests are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for the dev setup and the checks to run. Deferred ideas are tracked in [docs/backlog.md](docs/backlog.md).

---

License
-------

[](#license)

[MIT](LICENSE) © Samuel de Rougemont

###  Health Score

36

—

LowBetter than 79% of packages

Maintenance100

Actively maintained with recent releases

Popularity0

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity32

Early-stage or recently created project

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Unknown

Total

1

Last Release

0d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/024c3beb5cbe22cd3e3f3db6938cf893c09d0b3b90076e2e1566530cd0693372?d=identicon)[Nandan108](/maintainers/Nandan108)

---

Top Contributors

[![Nandan108](https://avatars.githubusercontent.com/u/354944?v=4)](https://github.com/Nandan108 "Nandan108 (37 commits)")

---

Tags

databaseormmysqlpostgresqlmariadbattributesactive-recordphp8ddldirty-tracking

###  Code Quality

TestsPHPUnit

Static AnalysisPsalm

Code StylePHP CS Fixer

Type Coverage Yes

### Embed Badge

![Health badge](/badges/nandan108-attrecord/health.svg)

```
[![Health](https://phpackages.com/badges/nandan108-attrecord/health.svg)](https://phpackages.com/packages/nandan108-attrecord)
```

###  Alternatives

[scienta/doctrine-json-functions

A set of extensions to Doctrine that add support for json query functions.

58825.2M48](/packages/scienta-doctrine-json-functions)[tommyknocker/pdo-database-class

Framework-agnostic PHP database library with unified API for MySQL, MariaDB, PostgreSQL, SQLite, MSSQL, and Oracle. Query Builder, caching, sharding, window functions, CTEs, JSON, migrations, ActiveRecord, CLI tools, AI-powered analysis. Zero external dependencies.

826.0k](/packages/tommyknocker-pdo-database-class)[longitude-one/doctrine-spatial

Doctrine multi-platform support for spatial types and functions, compliant with Doctrine 2.19, 3.1, and dev ones (3.2 and 4.0).

891.5M1](/packages/longitude-one-doctrine-spatial)[ramadan/easy-model

A Laravel package for enjoyably managing database queries.

111.6k](/packages/ramadan-easy-model)[moharrum/laravel-adminer

Adminer database management tool for your Laravel application.

451.0k](/packages/moharrum-laravel-adminer)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
